[SO-341] Rework auth-service into a multi-method identity service - #387
Open
ewitulsk wants to merge 2 commits into
Open
[SO-341] Rework auth-service into a multi-method identity service#387ewitulsk wants to merge 2 commits into
ewitulsk wants to merge 2 commits into
Conversation
One account, reachable by username+password or Sui wallet, linkable in either direction. Adds a Postgres-backed users/identities/invites store, role+scope JWT claims, and require_admin in auth-client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Follow-ups on the identity model, all cheap now and expensive later.
- `RegisterMethod` and `AddIdentityReq` were byte-identical untagged enums.
One `AuthMethod` tagged on `method` replaces both, and both handlers go
through a shared `resolve_method`. Untagged picks the first variant that
deserializes and drops unknown fields, so a future variant overlapping an
existing shape would bind to the wrong branch silently. Tests cover the
dispatch, the untagged body now being rejected rather than guessed, and an
unknown method naming itself in the error.
- Wire shape changes to `{invite, method, ...}`. Free right now: nothing in
the repo or the frontend calls `/register` or `/identities` yet.
- `identities` gains `metadata JSONB` and `verified_at`, added while the
table is still empty. Read-only — absent from `NewIdentity` and from `/me`,
since nothing writes them and `metadata` will hold provider-internal state.
- Migration 000001 is edited in place rather than superseded; it has never
been applied outside dev machines. Drop any local `auth_test` database.
- Docs claimed a new method was "a new kind value and nothing else" / "a
branch in the two matches". It is four places, and sign-in is not free.
Corrected in lib.rs, models.rs and the migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SO-341
auth-service was Sui-wallet-only: sign a challenge, get an admin JWT, and membership of a config allowlist was the entire authorization model. That works for operators and for nobody else.
One account is now reachable by several login methods. An account holds a role and an opaque scope; it's reached through one or more identities — today
password(username + Argon2id) andsui_wallet(address + signed challenge). Because they hang off a shared account, a wallet user can add a password and a password user can add a wallet, and either then signs them in. Adding a method later is a newkindvalue plus a branch in twomatches; nothing else moves.Split out of #382 so the identity change can be reviewed on its own.
Deliberately no email
The store holds usernames, Sui addresses, roles and opaque scope ids. No email column exists — usernames are opaque handles, not addresses.
The consequence is real and worth agreeing to: there is no password reset. Recovery is an admin minting a fresh invite. That's the right trade here — this fronts a system whose whole design premise is that we never hold personally identifying data, and an email table would be the first crack in it.
Accounts only exist by invitation
There's no self-serve signup. An invite carries the role and scope, and nothing about them is read from the registration body — so a redeemer can't promote themselves. Invites are single-use and time-boxed, claimed with a conditional
UPDATEso two simultaneous redemptions of the same link can't both win.One exception, and it's the bootstrap: a wallet on
admin_addressesis auto-provisioned as an admin on first login. That list is now a root-of-trust, and it's commented as such.A privilege escalation this closes
token-info's mutate routes were gated onrequire_auth, which only proves a token is valid. Once auth-service started issuing tokens tobusinessandindividualaccounts, any newly-created customer account could have mutated the token catalog.crates/auth-clientgainsrequire_admin;token-infouses it. Anything else gating a privileged operation onrequire_authwants the same treatment.VerifiedClaimsalso gainsuser_id,roleandscope.roledefaults to the least-privileged value if absent, so a version skew fails closed.Claims changed shape
subis now the account uuid, not a wallet address — an account may hold several login methods, so an address is one identity among many rather than the identity itself. The address moves to its own optionaladdressclaim.The admin frontend read
subas the address, sofrontend/src/api/authClient.tsmoves toaddressand gainsjwtRole. Wallet login still returnsaddressin the response body, so nothing else there changes.Blocking:
auth_prodbefore the next prod deployauth-service now has a hard Postgres dependency and will not boot without it. It ships to prod, is health-gated, and
deploy.shrolls back the whole planned set on the first failed gate — so a prod deploy without this database reverts everything deployed alongside it.The embedded migrations run themselves. The database and role do not.
This is called out in
config.prod.tomlat the line that needs it.Smaller decisions worth a look
/refreshre-reads role and scope from the database rather than copying them from the old token, so a role change or a disable lands at the next refresh instead of lingering for the rest of the window.POST /invites, unauthenticated. Keeping that port off the nginx proxy is the access control — anything that can reach it can mint an admin invite. Said plainly in the router's module doc..-_, lowercased for a case-insensitive unique index, soEvancan't be registered alongsideevanand impersonate it. Password rule is a length floor, not composition — composition rules push people towardPassw0rd!and buy nothing.Verification
The integration tests cover the behaviour a regression would actually break: wallet-then-password and password-then-wallet both resolving to one
user_id, an identifier being unclaimable twice, the last identity being unremovable, one account being unable to remove another's identity, invites carrying role and scope, single-use and expiry, and a failed registration leaving the invite open rather than burning it on a typo.They need a real Postgres and follow the existing
#[ignore]convention:AUTH_TEST_DATABASE_URL=postgres://…/auth_test cargo test -p auth-service -- --ignored🤖 Generated with Claude Code
https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr