HTTP API · 5. Identity and passports

Twenty-five registrations answer one question in four different ways: which principal is acting, what it is entitled to, and how that binding was established.

Planes covered: Auth rails (unified login) · Identity links and candidates · Passports, mint requests and presence · Principal resolution and capability policy.

Read chapter 0 first for the auth model, request-field notation and error shape.

Three things worth knowing before the tables:

  • The whole /v1/auth/* plane is Public by route-auth contract. That is by construction, a login rail that required a token would be useless. One route on it nonetheless enforces admin:write in its own handler; see §5.1.
  • Seven of these routes are gated off by default. The identity-link plane needs CORECRUXD_IDENTITY_LINKS; mint requests need CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS. Both default off.
  • Presence is populated by middleware, not by a route. Any request carrying X-Corecrux-Passport-Id touches presence in the background (mod.rs:1570).

5.1 Auth rails (unified login)

Seven registrations. Two rails: an OAuth-style device-code flow, and a Tailscale identity exchange.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
POST/v1/auth/device/startauth_device.rs:204 post_device_startpublic · Publicbody: client_name (String, dflt)keys device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval500-W
POST/v1/auth/device/tokenauth_device.rs:353 post_device_tokenpublic · Publicbody: device_code (String, req)Token response, or a pending or denied status400, 500-W
POST/v1/auth/device/approveauth_device.rs:283 post_device_approveadmin:write in the handler, Public by contractbody: user_code (String, req), tenant_id (String, req), scopes (Vec String, req), deny (bool, dflt)keys ok, decision, user_code, client_name400, 404, 409, 500-W
POST/v1/auth/device/refreshauth_device.rs:453 post_device_refreshpublic · Publicbody: refresh_token (String, req)keys access_token, token_type, expires_in, scopes, tenant_id, rail401, 500, 503-W
POST/v1/auth/device/revokeauth_device.rs:506 post_device_revokepublic · Publicbody: refresh_token (String, req)keys revoked500-W
POST/v1/auth/tailscale/tokenauth_rails.rs:207 post_tailscale_tokenpublic · Public, (identity comes from the Tailscale connection)keys access_token, token_type, expires_in, scopes, sub, tenant_id, rail401, 403, 503-W
GET/v1/auth/whoamiauth_rails.rs:180 get_whoamipublic · Public-keys login, rail, source, trusted, allowlisted-,R

POST /v1/auth/device/approve is the exception on this plane. The route-auth contract classes the whole /v1/auth/* prefix as Public, but the handler itself calls require_http_scopes with admin:write (auth_device.rs:292). An unauthenticated caller reaches the handler and is refused there. That is the correct outcome, approval is the step where scopes are granted, but it means the protection is the handler, not the contract. Under CORECRUXD_AUTH_MODE=off the handler check passes for everyone, and Public means the middleware will not stop it either.

The browser page that drives approval is /activate, a static asset outside the API contract whose form POSTs to this route (console.rs:314). See chapter 9.

GET /v1/auth/whoami reports trusted and allowlisted separately. trusted means the rail authenticated the caller; allowlisted means an operator has additionally permitted that identity. Both must be true for the Tailscale rail to issue a token.

503 on the token routes means the rail is not configured on this daemon, not that it failed.

5.2 Passports and presence

Six registrations. A passport is the acting identity a request declares through X-Corecrux-Passport-Id.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
GET/v1/passportspassports.rs:211 get_passportsadmin:read · Readquery: category (String, opt)keys passports, category_filter400-R
POST/v1/passportspassports.rs:494 post_passportadmin:write · AdminWritebody: id (String, req), category (String, req), sponsor_id (String, dflt), agent_work_gate (bool, dflt), is_default_for_category (bool, dflt), name (String, dflt), owner (String, dflt), position (String, dflt), company (String, dflt), notes (String, dflt)Passport record400, 409-W · facts
GET/v1/passports/{passportId}passports.rs:477 get_passportadmin:read · Readpath passportIdPassport record404-R
PATCH/v1/passports/{passportId}passports.rs:531 patch_passportadmin:read in the handler · Writepath passportId; body: category (String, dflt), agent_work_gate (bool, dflt), is_default_for_category (bool, dflt), sponsor_id (nullable String, dflt), reputation_tier (String, dflt), receipt_count (u64, dflt), name (nullable String, dflt), owner (nullable String, dflt), position (nullable String, dflt), company (nullable String, dflt), notes (nullable String, dflt)Updated passport record400, 404-W · receipt, facts
DELETE/v1/passports/{passportId}passports.rs:568 delete_passportadmin:read in the handler · Writepath passportIdDeletion result404, 500-W · facts
GET/v1/passports/presencepassports.rs:591 get_presenceadmin:read · Read-keys presence, count-,R

PATCH and DELETE on a passport check admin:read in the handler, not admin:write. The route-auth contract classes them Write and accepts admin:write, facts:write or integrations:install, so under enforce a write-class scope is still needed to reach the handler. Under the default shadow mode the middleware does not block, and the handler's own check is a read scope. On a default install, an admin:read token can delete a passport. If that matters to you, CORECRUXD_ROUTE_AUTH=enforce closes it.

POST /v1/passports is tenant-bound against the literal tenant default (passports.rs:152). 409 means the id already exists.

Presence is not written by any route on this plane. presence_middleware spawns a background touch on any request that carries X-Corecrux-Passport-Id, and skips the lock entirely when the header is absent (mod.rs:1570). A passport that never sends the header is invisible to GET /v1/passports/presence no matter how much traffic it generates.

5.3 Passport mint requests

Three registrations, gated off by default. This is the request-and-approve path for minting a passport without handing out admin:write.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
GET/v1/passport/mint-requests/pendingpassports.rs:241 get_pending_mint_requestsadmin:read · FeatureGatedquery: by_passport (String, opt)keys pending, count-CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS, default offR
POST/v1/passport/mint-requests/{request_id}/approvepassports.rs:271 post_mint_request_approveadmin:write · FeatureGatedpath request_id; body: approver_passport (String, dflt), category (String, dflt), name (String, dflt)keys status, minted, request_id, requester_id, category, passport_operation, passport_record_hash, passport_mutation_hash, receipt_id, receipt_record_id, receipt_session_id409, 500CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS, default offW · receipt, facts
POST/v1/passport/mint-requests/{request_id}/rejectpassports.rs:408 post_mint_request_rejectadmin:write · FeatureGatedpath request_id; body: approver_passport (String, dflt), category (String, dflt), name (String, dflt)keys status, minted, request_id, requester_id, receipt_id, receipt_record_id, receipt_session_id-CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS, default offW · receipt, facts

With the flag off the routes return 404 without touching state (mod.rs:230).

Both resolution routes emit a receipt whether the decision was approve or reject, and the reject path still returns a receipt_id. A rejection is evidence too.

409 on approve means the request has already been resolved. Approving twice does not mint twice.

Seven registrations, all gated off by default. An identity link binds a local passport to a remote fingerprint, and it requires both parties' signatures.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
GET/v1/identity/linksidentity_links.rs:460 get_identity_linksany-of admin:read, admin:write · Read-keys links, link, link_id, live and revoked401, 404CORECRUXD_IDENTITY_LINKS, default offR
POST/v1/identity/linksidentity_links.rs:97 post_identity_linkadmin:write · Writebody: local_passport_id (String, req), remote_fpr (String, req), remote_public_key_hex (String, req), created_at (String, req), sig_local (String, req), sig_remote (String, req)201, keys link, link_id401, 403, 404, 409CORECRUXD_IDENTITY_LINKS, default offW
POST/v1/identity/links/{linkId}/revokeidentity_links.rs:487 post_identity_link_revokeadmin:write · Writepath linkIdkeys link, link_id401, 404CORECRUXD_IDENTITY_LINKS, default offW
GET/v1/identity/candidatesidentity_links.rs:147 get_identity_candidatesany-of admin:read, admin:write · AdminReadquery: status (String, dflt)keys candidates, candidate, candidate_id400, 401, 403, 404CORECRUXD_IDENTITY_LINKS, default offR
POST/v1/identity/candidates/proposeidentity_links.rs:265 post_identity_candidates_proposeadmin:write · AdminWrite-keys created, examined, observations, by_source401, 403, 404, 500CORECRUXD_IDENTITY_LINKS, default offW
POST/v1/identity/candidates/{candidateId}/confirmidentity_links.rs:364 post_identity_candidate_confirmadmin:write · AdminWritepath candidateId; body: local_passport_id (String, req), remote_fpr (String, req), remote_public_key_hex (String, req), created_at (String, req), sig_local (String, req), sig_remote (String, req)201, keys candidate, candidate_id, link, link_id400, 401, 403, 404, 409CORECRUXD_IDENTITY_LINKS, default offW
POST/v1/identity/candidates/{candidateId}/rejectidentity_links.rs:419 post_identity_candidate_rejectadmin:write · AdminWritepath candidateIdkeys candidate, candidate_id400, 401, 404CORECRUXD_IDENTITY_LINKS, default offW

A candidate is not a link and does not resolve. The candidate store records observations that two identities might be the same, produced by proposers you run explicitly with POST /v1/identity/candidates/propose. Nothing in the daemon treats a candidate as a binding. Only confirm, which requires both signatures, exactly as the direct link route does, creates one.

Reject preserves the audit trail. POST .../reject marks the candidate rejected without deleting it. That is deliberate: the record that someone proposed and someone declined is itself evidence.

403 on link creation means a signature did not verify. 409 means the link already exists. Revocation is idempotent.

With CORECRUXD_IDENTITY_LINKS off, the default, all seven routes return 404.

5.5 Principal resolution and capability policy

Two registrations.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
GET/v1/principal/resolveprincipal.rs:30 get_resolve_principalany-of admin:read, sessions:read · Readquery: session_id (String, opt), passport_id (String, opt), include_candidates (String, opt)Resolved principal400, 500CORECRUXD_IDENTITY_LINKS extends the response with candidates; default offR
GET/v1/policy/capabilitiespolicy.rs:16 get_policy_capabilitiesany-of query:read, facts:read, admin:read · Read-Capability policy view-,R

/v1/principal/resolve works with the identity-link flag off; the flag only controls whether the include_candidates expansion has anything to return (principal.rs:89). Requesting candidates on a daemon with the flag off is not an error; you get a resolution without them.

/v1/policy/capabilities is the widest read on this plane: any of three read scopes will do.

5.6 Failure modes on this plane

SymptomMost likely cause
503 from /v1/auth/device/refresh or /v1/auth/tailscale/tokenThe rail is not configured on this daemon. Not a credential problem.
403 from /v1/auth/tailscale/token with a valid Tailscale identitytrusted is true but allowlisted is false. Check GET /v1/auth/whoami.
An admin:read token successfully deleting a passportExpected under the default shadow route-auth mode: the handler checks admin:read. Set CORECRUXD_ROUTE_AUTH=enforce.
A passport with heavy traffic missing from GET /v1/passports/presencePresence only records requests that carry X-Corecrux-Passport-Id.
404 on every /v1/identity/* routeCORECRUXD_IDENTITY_LINKS is off. It is off by default.
404 on /v1/passport/mint-requests/*CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS is off. It is off by default.
403 on POST /v1/identity/linksOne of the two signatures did not verify. Both are required.
409 on POST /v1/passport/mint-requests/{id}/approveAlready resolved. Approving twice does not mint twice.
include_candidates returning nothingThe identity-link flag is off, so there are no candidates to include. This is not an error.

Sources