Tutorials

Adding a new secret

You want to add a new credential — say, a Slack webhook URL or a Linear API token — so other Constellation code can ask secrets.slack() for it instead of reading from a .env file.

This is a worked example using a hypothetical slack secret. The pattern is identical for any other credential.

Step 1 — Put the credential in 1Password

1Password → Engineering vault → New Item → API Credential.

  • Name: Slack Bot Token (something descriptive — this is what you’ll reference later)

  • Fields: add whatever fields the credential has. For a Slack bot you might add bot_token and signing_secret.

Reference URIs (use these in the next step):

  • op://Engineering/Slack Bot Token/bot_token

  • op://Engineering/Slack Bot Token/signing_secret

Sanity-check from your laptop:

op read 'op://Engineering/Slack Bot Token/bot_token'

If that prints the value, you’re good.

Step 2 — Add a Pydantic model

In src/constellation_utils/secrets/models.py, add a new class mirroring the existing models:

class SlackSecrets(BaseModel):
    """Slack bot credentials."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    bot_token: str = Field(..., description="Slack bot user OAuth token (xoxb-...).")
    signing_secret: str = Field(..., description="Slack signing secret for request verification.")

frozen=True means consumers can’t mutate the credential. extra="forbid" means an unexpected field in YAML triggers a validation error instead of being silently ignored.

Step 3 — Add the YAML mapping

Edit both src/constellation_utils/config/secrets.testing.yaml and secrets.production.yaml (or just production, if there’s only one Slack workspace):

slack:
  bot_token:       op://Engineering/Slack Bot Token/bot_token
  signing_secret:  op://Engineering/Slack Bot Token/signing_secret

Step 4 — Add backend methods

In src/constellation_utils/secrets/_backends.py, the _Backend Protocol gets a new method, and both real backends implement it.

# Protocol
class _Backend(Protocol):
    def read_r2(self, uri_map: dict[str, str]) -> R2Secrets: ...
    def read_slack(self, uri_map: dict[str, str]) -> SlackSecrets: ...
# OpCLIBackend
def read_slack(self, uri_map: dict[str, str]) -> SlackSecrets:
    resolved = {field: self.read_one(uri) for field, uri in uri_map.items()}
    return SlackSecrets.model_validate(resolved)
# EnvBackend
SLACK_ENV_MAP = {
    "bot_token":      "SLACK_BOT_TOKEN",
    "signing_secret": "SLACK_SIGNING_SECRET",
}

def read_slack(self, uri_map: dict[str, str]) -> SlackSecrets:
    del uri_map  # env-var reads use the fixed map above
    values: dict[str, str] = {}
    missing: list[str] = []
    for field, env_name in self.SLACK_ENV_MAP.items():
        val = os.environ.get(env_name)
        if val is None:
            missing.append(env_name)
        else:
            values[field] = val
    if missing:
        raise ConstellationAuthError(
            "EnvBackend selected but required Slack env vars are missing.\n"
            f"  missing : {', '.join(missing)}"
        )
    return SlackSecrets.model_validate(values)

If you also want EnvBackend to be selected automatically when SLACK_BOT_TOKEN is set (so that tests for Slack-using code don’t need op installed), extend the env-trigger check inside select_backend():

# in _backends.py select_backend()
if "R2_ACCESS_KEY_ID" in os.environ or "SLACK_BOT_TOKEN" in os.environ:
    return EnvBackend()

This is optional — leave it out if Slack is only ever read on real laptops/rigs.

Step 5 — Expose the public function

In src/constellation_utils/secrets/__init__.py:

@lru_cache(maxsize=1)
def slack() -> SlackSecrets:
    """Return the Slack credentials for the current profile."""
    cfg = load_profile()
    if "slack" not in cfg:
        raise ConstellationAuthError(
            "the active profile is missing a `slack:` block."
        )
    backend = select_backend()
    return backend.read_slack(cfg["slack"])

Don’t forget to add it to __all__ and import the model.

Step 6 — Tests

Add coverage in three places:

  • tests/test_models.py — required-fields, frozen, extra-rejected

  • tests/test_backends.py — env backend success / missing-var, op CLI happy path, public function caching

  • tests/test_config.py — both profile YAMLs have a slack: block with the expected fields and op://Engineering/ URIs

Run the full suite:

uv run pytest
uv run ruff check
uv run mypy src

Step 7 — Documentation

Update README.md’s “Available secrets” table and the package’s docs (this site) to mention the new function. Bump the version in pyproject.toml.

Step 8 — Open the PR

Track the change under the Secrets Manager Linear project. Reviewers will look at:

  • Does the 1Password item exist and have the right fields?

  • Does the YAML map every field the model declares?

  • Is EnvBackend.read_<name> consistent with how the other secrets handle missing-var errors?

  • Are tests covering the same shape as the existing R2 tests?

Once merged, consumers can do:

from constellation_utils import secrets

slack = secrets.slack()
# SlackSecrets(bot_token=..., signing_secret=...)

And constellation doctor will (if extended) include a green ✓ for Slack reachability.