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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions