Skip to content

feat(agentex): identity-link storage with encrypted credentials at rest - #409

Merged
michael-chou359 merged 1 commit into
mainfrom
mc/identity-link-storage
Aug 25, 2026
Merged

feat(agentex): identity-link storage with encrypted credentials at rest#409
michael-chou359 merged 1 commit into
mainfrom
mc/identity-link-storage

Conversation

@michael-chou359

@michael-chou359 michael-chou359 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What

Part of the event-driven agents work — the Slack and Linear gateways that let a
platform-side webhook invoke an agent. Those gateways ship today, but every turn they
dispatch runs as a single shared bot service account, so the agent acts with one fixed
identity no matter who asked. This PR is the storage groundwork for running a turn as
the invoking human instead.

It adds the mapping from an external chat identity (Slack/Linear user) to an SGP user,
plus that user's SGP API key held encrypted at rest.

Why hold a credential at all. Reading a user's connected integrations (Notion,
Linear, …) from the secrets service requires authenticating as that user — the
owner of a user-scoped secret is derived from the calling identity and cannot be passed
as a parameter. An event-driven turn has no user session: a webhook is a
server-to-server POST, so there is no caller credential to forward. Making an agent act
as the person who asked therefore requires a stored credential belonging to them. Keys
are minted with an expiry so what's held is bounded rather than indefinite.

Nothing consumes this yet — no behavior changes. Gateway wiring and the link flow
follow in a separate PR.

The linking flow this enables

The mapping can't be derived — nothing in a webhook says anything about SGP. So it's
established in the one moment where both identities are authenticated simultaneously,
with a server-side nonce tying two requests together. [this PR] marks what lands
here; everything else is the follow-up.

1.  Slack event / slash command arrives
      Slack's HMAC proves the Slack side — only Slack could have signed it.
      We hold a trusted (team_id, user_id) and no idea who that is in SGP.
               │
2.  park it in a nonce → single-use, short TTL
      {provider, team_id, external_user_id, display_name, pending_turn}
      The browser receives only an opaque token.
               │
3.  user clicks → GET /integrations/<provider>/link?nonce=…
      Route is NOT auth-whitelisted, so the auth middleware runs and the
      browser's own session resolves the SGP identity.
               │
4.  both halves now present in ONE request
      → confirmation screen naming both sides
               │
5.  POST → mint an API key for that user, authenticated as them
      → encrypt + store the mapping and key            [this PR]
      → consume the nonce, invalidate the cache
               │
6.  every later event: resolve identity → decrypt key  [this PR]
      → forward it so the agent's tools act as that person

Why a nonce and not ids in the URL. A query string is user-editable. Given
?slack_user=<someone else>, an attacker could click their own link while signed in as
themselves and bind that person's chat identity to their own SGP account — after
which the victim's messages would run as the attacker, using the attacker's
integrations, with the victim's prompts landing in the attacker's account. Parking the
verified identity server-side and handing out an opaque token means nothing in the URL
is meaningful, so nothing in it is forgeable.

Change

identity_links
  provider + external_team_id + external_user_id  ->  sgp_user_id / sgp_account_id
  credential_ciphertext, credential_expires_at    ->  nullable
  linked_via                                      ->  explicit | email_match | manual
  revoked_at                                      ->  tombstone, never delete

provider is a discriminator from the start rather than a Slack-only table, since both
gateways need this and retrofitting one later is worse than carrying a column now.

  • Two partial unique indexes, scoped to revoked_at IS NULL so revoked rows remain
    for audit:
    • one active SGP identity per external identity — the mapping must be a function
    • one active external identity per SGP user per workspace — two accounts pointing at
      one user is the shape an identity hijack takes, and no legitimate case needs it
  • credential_encryption.py — Fernet, so a tampered ciphertext fails to decrypt
    instead of yielding garbage we'd then present downstream as a bearer token. The key
    comes from AGENTEX_CREDENTIAL_ENCRYPTION_KEY, delivered with the other platform
    secrets and deliberately not stored in this database: a database dump alone is not
    sufficient to use the credentials. Fails closed — a missing or malformed key raises
    rather than writing plaintext.
  • Repository asymmetry, on purpose. The credential is reachable through exactly one
    method (get_credential), returning a bare string. Every other read returns
    IdentityLinkEntity, which carries has_credential: bool and no key. Entities get
    logged, cached and serialized, so the fewer code paths that can put key material in
    one, the better.
  • upsert_link revokes-then-inserts in one transaction (auditable history, and
    re-linking is idempotent against the partial index rather than racing it), and clears
    the superseded row's ciphertext.
  • cryptography promoted to a direct dependency. It was already present transitively,
    but the correctness of a stored credential shouldn't depend on another package
    continuing to pull it in.

Migration safety

Schema-only, and there is nothing to backfill even in principle — the mapping can't be
derived from existing data, it only comes into being when a user authenticates both
identities in one moment.

  • Table create guarded on a catalog check; indexes use IF NOT EXISTS — re-running is
    a no-op.
  • Indexes built CONCURRENTLY inside an autocommit_block(). The table is empty here
    so a plain build would also be safe; this keeps it consistent with the repo rule and
    removes the question.
  • No foreign keys — sgp_user_id / sgp_account_id belong to SGP and the provider ids
    are external, so there's nothing local to reference.
  • Migration linter: no findings.

Testing

  • 13 unit tests for encryption, focused on failing loudly: missing key, blank key,
    malformed key, tampered ciphertext, and a key rotated out from under existing rows
    (which must surface as "re-link needed", never as "no credential").

  • 17 repository integration tests covering the partial indexes, the supersede/revoke
    transaction, and ciphertext round-tripping. These exist because the behaviors that
    matter here are enforced by the schema and by SQL rather than by Python — a mock-based
    test would assert the intent instead of the constraint.

    Includes the regression that superseding a link previously tombstoned the old row but
    left its ciphertext intact, so every re-link accumulated another still-valid key at
    rest (test_superseding_a_link_clears_the_old_credential). That one was found by a
    live round-trip, not by a mock.

    These could not be run on my machine — the shared isolated_repositories fixture
    starts a MongoDB container that won't boot locally (mongod exits 51 under Rancher
    Desktop) — so they were verified by hand against a live Postgres and then confirmed in
    CI: 17/17 passed against CI's Postgres, no skips.

  • ruff clean; pre-commit hooks pass. Full CI green (unit 679 passed, integration 162
    passed), with identity_link_repository.py and credential_encryption.py both at
    100% line coverage.

Before anything uses this

AGENTEX_CREDENTIAL_ENCRYPTION_KEY must be present in the environment, or every
credential path raises (by design — it will not silently store plaintext). Generate
with:

python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'

🤖 Generated with Claude Code

Part of the event-driven agents work (the Slack and Linear gateways). Those
gateways dispatch every turn as a single shared bot service account, so an
agent acts with one fixed identity regardless of who asked. This is the
storage groundwork for running a turn as the invoking human instead.

Adds the mapping from an external chat identity (Slack/Linear user) to an SGP
user, along with that user's SGP API key held encrypted.

Why hold a credential at all: reading a user's connected integrations from
the secrets service requires authenticating AS that user — the owner of a
user-scoped secret is derived from the calling identity and cannot be passed
as a parameter. An event-driven turn has no user session (a webhook is a
server-to-server POST, so there is no caller credential to forward), so
acting as a person requires a stored credential belonging to them. Keys are
minted with an expiry so what's held is bounded.

  identity_links
    provider + external_team_id + external_user_id  -> sgp_user_id/account
    credential_ciphertext, credential_expires_at    -> nullable
    linked_via                                       -> explicit|email_match|manual
    revoked_at                                       -> tombstone, not delete

`provider` is a discriminator from the start rather than a Slack-only table,
since both gateways need this and retrofitting one later is worse.

Two partial unique indexes, scoped to active rows so revoked rows stay for
audit: one active SGP identity per external identity (the mapping must be a
function), and one active external identity per SGP user within a workspace
(two accounts pointing at one user is the shape an identity hijack takes).

Encryption uses Fernet (authenticated, so a tampered ciphertext fails to
decrypt rather than yielding garbage we'd then present as a bearer token).
The key comes from AGENTEX_CREDENTIAL_ENCRYPTION_KEY, delivered with the
other platform secrets and deliberately not stored in this database, so a
database dump alone is not sufficient to use the credentials. It fails
closed: a missing or malformed key raises rather than writing plaintext.

Deliberate asymmetry in the repository: the credential is reachable through
exactly one method, and IdentityLinkEntity carries `has_credential: bool`
rather than the key. Entities get logged, cached and serialized, so the
fewer paths that can place key material in one, the better.

Nothing consumes this yet — no behavior changes. The gateway wiring and the
link flow follow separately.

Testing:
- 13 unit tests for encryption, focused on failing loudly (missing key,
  malformed key, tampered ciphertext, key rotated out from under stored rows)
- 17 repository integration tests covering the partial indexes, the
  supersede/revoke transaction and ciphertext round-tripping. These collect
  cleanly but could not be executed locally: the shared fixture starts a
  MongoDB container that will not run in this environment (mongod exits 51).
  The same behaviors were verified by hand against a live Postgres, including
  the regression that superseding a link used to leave a still-valid
  credential on the tombstoned row.
- Migration linter reports no findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michael-chou359
michael-chou359 requested a review from a team as a code owner August 25, 2026 18:07
Comment on lines +159 to +160
credential: str | None = None,
credential_expires_at: datetime | None = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unbounded credential expiration

credential and credential_expires_at are independent optional arguments, so callers can store a credential with a null expiration; credential_is_usable then treats that credential as indefinitely usable, weakening the intended bounded credential lifecycle.

Knowledge Base Used: Agent access and API keys

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/domain/repositories/identity_link_repository.py
Line: 159-160

Comment:
**Unbounded credential expiration**

`credential` and `credential_expires_at` are independent optional arguments, so callers can store a credential with a null expiration; `credential_is_usable` then treats that credential as indefinitely usable, weakening the intended bounded credential lifecycle.

**Knowledge Base Used:** [Agent access and API keys](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/scale-agentex/-/docs/agent-access-and-api-keys.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

@michael-chou359
michael-chou359 merged commit 9f8d539 into main Aug 25, 2026
47 checks passed
@michael-chou359
michael-chou359 deleted the mc/identity-link-storage branch August 25, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant