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.
Problem
When a team member is removed via
DELETE /api/teams/:id/users/:pubkey(handler at api/src/api/http/teams.rs:211), only theteam_usersrow 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_atpasses (no expiry on most regular team-admin authorizations) or an admin manually revokes viaPOST /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_usersdoes 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)
authorizationstable, the in-memory signer cache, therevoked_atcolumn).Synvya/serveris 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.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
authorizationstable has no column linking a row to the team member who holds it: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) NULLtoauthorizations. NULL allowed so existing rows are valid.2. Populate at issuance
Update
AuthorizationRepository::create()to acceptissued_to_pubkey: Option<&str>and write it.Update the two callers:
add_authorization(POST /teams/:id/keys/:pubkey/authorizations): passSome(&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): passSome(&auth.pubkey). Complements the existingsupport:{caller}label.3. Cascade in
remove_userAfter the
team_usersrow is deleted, run:For each returned
(id, bunker_public_key), sendAuthorizationCommand::Remove { bunker_pubkey }overauth_state.auth_txso the signer drops the in-memory handler. Mirrors the pattern inrevoke_authorizationand the newrelease_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
issued_to_pubkey = bob, remove Bob, verifyrevoked_atis set and bunker is no longer in the signer's map.issued_to_pubkey = NULL(pre-migration) is NOT touched by the cascade (we don't know who held it).Backwards compatibility
Rows created before this migration have
issued_to_pubkey = NULLand 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_authorizationstable — separate code path, separate revocation flow, not affected.connected_client_pubkey— staying as-is, this issue replaces (not modifies) its role for the cascade purpose.Related
support:{caller}) used byrelease_team_support_accessis a stopgap for the same underlying gap; onceissued_to_pubkeylands, the support release endpoint can switch to filtering on that column instead and the label becomes purely audit-decorative.connected_client_pubkeylimitation.