Skip to content

remove_user should cascade authorization revocation #55

@alejandro-runner

Description

@alejandro-runner

Problem

When a team member is removed via DELETE /api/teams/:id/users/:pubkey (handler at api/src/api/http/teams.rs:211), only the team_users row is deleted. Any authorizations the removed user holds against the team's stored keys remain live until they expire or are explicitly revoked elsewhere.

Concretely: the removed user can still sign as the restaurant via their bunker URL until expires_at passes (no expiry on most regular team-admin authorizations) or an admin manually revokes via POST /api/admin/authorizations/:id/revoke. Membership and signing access have drifted apart.

This shows up most cleanly in the support-users flow (docs/synvya/support-users.md): after a support agent provisions a restaurant and hands it off to the owner, removing themselves from team_users does not revoke the long-lived authorization they minted during creation. Same issue applies more broadly — owner removing a former employee, admin removing a malicious member, etc.

Why this belongs in Keycast (not Synvya/server, not the client)

  • Authorizations are NIP-46 signing credentials. The policy boundary lives in Keycast (the authorizations table, the in-memory signer cache, the revoked_at column).
  • Synvya/server is not in the request path for member removal. The Restaurant app calls Keycast directly. Putting the cascade there would require either a Keycast → server event or an extra client round trip — both worse than doing it in the handler that already owns the row.
  • Multiple clients can call remove_user (Restaurant app, scripts, future mobile, third-party tooling). Fixing it at every caller is N times the work and one forgotten path is a leak. One server-side fix covers all callers atomically.

Why the data model doesn't support a clean cascade today

The authorizations table has no column linking a row to the team member who holds it:

id, tenant_id, stored_key_id, policy_id,
secret_hash, bunker_public_key, relays,
max_uses, expires_at,
connected_client_pubkey, connected_at,
label,
created_at, updated_at, revoked_at, revoked_reason

The two pubkey-shaped columns don't help:

  • bunker_public_key — identifies the derived bunker keypair, not the holder.
  • connected_client_pubkey — populated lazily on first NIP-46 connect, NULL until then, and even when set it's the NIP-46 client session pubkey (a per-session ephemeral key), not the user's account pubkey.

This is a deliberate model: authorizations are capability tokens (bunker URL + secret), transferable, not bound to a user. Correct for OAuth third-party apps and the server's always-on bunker. But for the team-member case, it leaves "who holds this" unrecorded.

The support-users PR (#54) works around this by stamping label = \"support:{caller_pubkey_hex}\" at grant time, which is enough for the support release flow but doesn't generalize to regular team authorizations.

Proposed fix

Small, contained: one column + one auto-populate + one cascade query.

1. Migration

Add issued_to_pubkey CHAR(64) NULL to authorizations. NULL allowed so existing rows are valid.

ALTER TABLE authorizations
  ADD COLUMN issued_to_pubkey CHAR(64);
CREATE INDEX authorizations_issued_to_pubkey_idx
  ON authorizations (issued_to_pubkey)
  WHERE revoked_at IS NULL;

2. Populate at issuance

Update AuthorizationRepository::create() to accept issued_to_pubkey: Option<&str> and write it.

Update the two callers:

  • add_authorization (POST /teams/:id/keys/:pubkey/authorizations): pass Some(&auth.pubkey). In every current Synvya flow the caller IS the holder (owner mints for themselves, Maria mints for herself, invited Bob mints for himself, server mints under service-auth and is itself the holder). Behavior-preserving auto-populate.
  • grant_team_support_access (POST /admin/teams/:id/support-access): pass Some(&auth.pubkey). Complements the existing support:{caller} label.

3. Cascade in remove_user

After the team_users row is deleted, run:

UPDATE authorizations a
SET revoked_at = NOW(),
    revoked_reason = 'team_member_removed',
    updated_at = NOW()
FROM stored_keys sk
WHERE a.stored_key_id = sk.id
  AND sk.team_id = $1
  AND sk.tenant_id = $2
  AND a.tenant_id = $2
  AND a.issued_to_pubkey = $3
  AND a.revoked_at IS NULL
RETURNING a.id, a.bunker_public_key

For each returned (id, bunker_public_key), send AuthorizationCommand::Remove { bunker_pubkey } over auth_state.auth_tx so the signer drops the in-memory handler. Mirrors the pattern in revoke_authorization and the new release_team_support_access.

Audit: tracing::info!(\"Authorizations cascaded on member removal: team={team_id} removed_user={pubkey} count={count}\").

The handler does the cascade in the same transaction as the membership delete so a partial failure doesn't leave the system in a half-state.

4. Tests

  • Member removal cascades: insert team + stored_key + authorization with issued_to_pubkey = bob, remove Bob, verify revoked_at is set and bunker is no longer in the signer's map.
  • Backwards compat: an authorization with issued_to_pubkey = NULL (pre-migration) is NOT touched by the cascade (we don't know who held it).
  • Self-removal: Maria removes herself after handoff to Joe, her own authorizations are revoked, Joe's are untouched.
  • Last-admin invariant unchanged: existing 403 still fires when a sole admin tries to remove themselves.

Backwards compatibility

Rows created before this migration have issued_to_pubkey = NULL and don't match the cascade query. That's identical to today's behavior — no regression. Forward-only fix; new authorizations get the column populated, member-removal cleanup works for them. Operators can backfill historical rows manually if a sweep is desirable.

Out of scope for this issue

  • OAuth oauth_authorizations table — separate code path, separate revocation flow, not affected.
  • The lazy/null-state of connected_client_pubkey — staying as-is, this issue replaces (not modifies) its role for the cascade purpose.
  • Promotion/demotion of team admins — independent of this gap.

Related

  • PR #54 introduces support-users. The label-based filter (support:{caller}) used by release_team_support_access is a stopgap for the same underlying gap; once issued_to_pubkey lands, the support release endpoint can switch to filtering on that column instead and the label becomes purely audit-decorative.
  • docs/synvya/support-users.md §3.3 describes the label-based filter and explicitly notes the connected_client_pubkey limitation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions