You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make "grant access by email" reveal nothing about who exists in the system, and reject self-invites.
Current state
PR #1192 lets POST /grants create a grant from a user.identifier (email in the default schema; the lookup key is whatever the user schema designates via x-identifier, ADR 058). Its contract says: "Exactly one active match is required; zero or several resolve as not found", returning grant.principal_not_found. So the response tells the caller whether the address belongs to an active user. A duplicate grant returns grant.already_exists (409), which is also distinguishable.
The Console journey built on top of this shows the inviter a fixed message: "If the user exists in our system, they have been granted access to your project." That message is only honest if the API itself does not leak the difference.
Separately, internal/api/security.go:110-116 allowlists QueryUsers for sessions. Its only Console consumer is the add-admin picker that the email flow replaces, and it hands any operator a page of up to 100 users, which defeats the neutral message from the other side.
Decisions that shape this work
On the identifier path, zero matches returns the same success-shaped response as a created grant. The server does nothing and says nothing; reasons stay in the server logs, as PR feat(api): accept user and team locators on grants #1192 already does for ambiguity.
On the identifier path, an existing identical grant also returns the success shape instead of grant.already_exists. Nothing is created twice.
The explicit user_id path keeps its current errors; ids are not secrets the way identifiers are.
Granting to yourself (the resolved user equals the caller) is rejected with a visible error. Your own address tells you nothing new, so this leaks nothing.
Worth noting for the implementation: a neutral response does not stop volume or timing probes, so this endpoint is a rate-limiting candidate.
QueryUsers is removed from the session allowlist once the Console picker is gone.
Write API tests first: create-by-identifier with a matching user returns success and writes the grant; with an unknown identifier it returns the same response shape and writes nothing; repeated with the same identifier it returns the same shape and writes nothing new; the caller's own identifier is rejected with a distinct error; create-by-user_id keeps today's grant.principal_not_found and grant.already_exists behavior; a session calling POST /users/query is refused after the allowlist change. Then implement.
Tasks
Change the identifier-path responses in the grant create flow (internal/service/grant.go) as decided above, and update the contract text in api/openapi/endpoints/grants/.
Add the self-grant check where the identifier resolves (compare against the session's user id).
Drop QueryUsers from the session allowlist in internal/api/security.go, coordinated with the Console ticket that removes the picker.
Part of #1022. Builds on PR #1192.
Goal
Make "grant access by email" reveal nothing about who exists in the system, and reject self-invites.
Current state
PR #1192 lets
POST /grantscreate a grant from auser.identifier(email in the default schema; the lookup key is whatever the user schema designates viax-identifier, ADR 058). Its contract says: "Exactly one active match is required; zero or several resolve as not found", returninggrant.principal_not_found. So the response tells the caller whether the address belongs to an active user. A duplicate grant returnsgrant.already_exists(409), which is also distinguishable.The Console journey built on top of this shows the inviter a fixed message: "If the user exists in our system, they have been granted access to your project." That message is only honest if the API itself does not leak the difference.
Separately,
internal/api/security.go:110-116allowlistsQueryUsersfor sessions. Its only Console consumer is the add-admin picker that the email flow replaces, and it hands any operator a page of up to 100 users, which defeats the neutral message from the other side.Decisions that shape this work
grant.already_exists. Nothing is created twice.user_idpath keeps its current errors; ids are not secrets the way identifiers are.QueryUsersis removed from the session allowlist once the Console picker is gone.Dependencies
Test first (TDD)
Write API tests first: create-by-identifier with a matching user returns success and writes the grant; with an unknown identifier it returns the same response shape and writes nothing; repeated with the same identifier it returns the same shape and writes nothing new; the caller's own identifier is rejected with a distinct error; create-by-
user_idkeeps today'sgrant.principal_not_foundandgrant.already_existsbehavior; a session callingPOST /users/queryis refused after the allowlist change. Then implement.Tasks
internal/service/grant.go) as decided above, and update the contract text inapi/openapi/endpoints/grants/.QueryUsersfrom the session allowlist ininternal/api/security.go, coordinated with the Console ticket that removes the picker.Reference
PR #1192 (
user-locator.yaml,resolveUserByIdentifier);internal/domain/grant.go:19(ErrGrantPrincipalNotFound); ADR 058 (identifier designation); ADR 054 (collaboration grants).