docs: add IAM enforcement projection inventory and design

This commit is contained in:
joshsiv-mulga
2026-10-12 17:18:29 +10:00
parent af19f3f5c1
commit ce0c75c788
2 changed files with 646 additions and 0 deletions
@@ -0,0 +1,281 @@
# IAM enforcement inventory
**Status:** Discovery record for ADR-0005 S6(1) and rollout evidence item 1; describes present code only and authorises no change.
This inventory lists every direct consumer of IAM private state across Spinifex, Predastore, Goanna and Bluebottle, and records the current authentication flow at each entry point.
The design that replaces these reads is `iam-enforcement-projection-design.md` in this directory; D-numbers below refer to its decision register (Section 14 of that document).
AWSGW findings pinned by characterisation tests are listed in Section 7.
## Sources and conventions
| Repo | Revision read | Path prefix used below |
|---|---|---|
| Spinifex | `feat/adr-0005-iam-enforcement-projection` at `976df15d7` | relative to `spinifex/spinifex/` |
| Predastore | `origin/dev` at `37ef410` (`predastore-wt-iamchar`) | `predastore:` |
| Goanna | `origin/dev` at `b4c6b31` (`goanna-wt-iamchar`), code reading only; not characterised in Phase 0 (D6) | `goanna:` |
| Bluebottle | `dev` at `f2f4149` | `bluebottle:` |
"IAM private state" means the `spinifex-iam-*`, `spinifex-accounts` and `iam-account-<id>` KV buckets and their records, access-key and session secrets, the master key that encrypts them, stored policy documents, role and trust records, STS session records and tokens, OIDC provider records, and any type that encodes those records.
Locations were found with the allyourbase index, then confirmed by direct reads, because the index was stale for `handlers/iam/service_impl.go` and does not cover the two worktrees.
Line numbers are those at the revisions above.
Process names: **awsgw** is the AWS gateway role (`runtime/roles/awsgw`), which hosts the IAM and STS implementations in-process; **daemon** is the Spinifex node daemon; **vpcd** hosts guest metadata; **spx** is the operator CLI; **s3d** is the Predastore server; **goannad** is the Goanna server.
## 1. IAM private records today
| Bucket | Key | Record (`handlers/iam/types.go` unless stated) | Secret fields |
|---|---|---|---|
| `spinifex-iam-access-keys` | `<AKID>` | `AccessKey` (68): `access_key_id`, `secret_access_key`, `user_name`, `account_id`, `status`, `created_at` | `secret_access_key` is AES-256-GCM under the master key, no associated data |
| `spinifex-iam-users` | `<account>.<userName>` | `User` (36): `user_id`, `arn`, `access_keys`, `attached_policies`, `groups`, `inline_policies` | none |
| `spinifex-iam-groups` | `<account>.<groupName>` | `Group` (53) | none |
| `spinifex-iam-roles` | `<account>.<roleName>` | `Role` (111): `role_id`, `arn`, `assume_role_policy_document`, `max_session_duration`, `attached_policies`, `inline_policies` | none |
| `spinifex-iam-policies` | `<account>.<policyName>` | `Policy` (84): `arn`, `policy_document`, versions | none |
| `spinifex-iam-instance-profiles` | `<account>.<name>` | `InstanceProfile` (128) | none |
| `spinifex-accounts` | `<account>` | `Account` (182): `status` ACTIVE, SUSPENDED or TERMINATING | none |
| `iam-account-<account>` | `OIDCProviderKey(issuer)` (`handlers/iam/oidc_providers.go:42`) | OIDC provider record | none |
| `spinifex-iam-session-credentials` | `<ASIA AKID>` | `SessionCredential` (`handlers/sts/session_credentials.go:40`): principal type, role/user IDs and ARNs, `expires_at` | `secret_encrypted` (AES-GCM, master key); `session_token_hmac` (HMAC-SHA256 keyed by the same master key, `handlers/sts/assume_role.go:613`) |
Bucket constants are declared at `handlers/iam/service_impl.go:38-45` and `handlers/sts/session_credentials.go:23`.
The master key is one 32-byte file, `<config>/master.key`, loaded group-readable with `masterkey.ReadShared` or `LoadShared` (`bluebottle:pkg/masterkey/masterkey.go:50-58`).
It encrypts IAM and session secrets and keys the session-token HMAC, and the same key also seals ACM, ECR, RDS, EKS, Ochre and Bedrock secrets (Section 4).
## 2. Consumer matrix
One classification per row, from: authority, credential verifier, policy evaluator, role resolver, session consumer, bootstrap path, invalid private-store reader.
Rows marked **CLI** or **test** are operator or test-support code.
### 2.1 Spinifex
| # | Location | Process | Reads / writes | Classification |
|---|---|---|---|---|
| S1 | `handlers/iam/service_impl.go:133` (`NewIAMServiceImpl`), all `IAMServiceImpl` methods | awsgw | Creates, migrates, reads and writes every IAM bucket in Section 1 except sessions; holds the master key (210) | authority |
| S2 | `handlers/sts/session_credentials.go:61-230`, `handlers/sts/assume_role.go:543-600` (`mintSession`) | awsgw | Writes and reads `spinifex-iam-session-credentials`; encrypts the session secret and HMACs the token with the raw master key | authority. Borderline: ADR-0005 S2 names IAM the logical authority for session validity, but STS physically owns this store today. |
| S3 | `handlers/iam/imds_rpc.go:34` (`SubscribeIMDSResponders`), `handlers/sts/imds_rpc.go:30` | awsgw | Serves instance-profile, `GetRole` and `AssumeRoleForInstance` over NATS to vpcd | authority |
| S4 | `handlers/sts/assume_role_with_web_identity.go:197-215` (`verifyOIDCProviderRegistered`) | awsgw | Opens `iam-account-<roleAccount>` with its own JetStream handle and reads `OIDCProviderKey(issuer)`, bypassing `IAMService` | invalid private-store reader |
| S5 | `handlers/sts/role_arn.go:27` (`ResolveRoleByARN`), used by `assume_role.go:167`, `assume_role_with_web_identity.go:83`, `gateway/sts.go:108` | awsgw | `IAMService.GetRole`: `RoleId`, `Arn`, `AssumeRolePolicyDocument`, `MaxSessionDuration` | role resolver |
| S6 | `handlers/sts/assume_role.go:248-290`, `web_identity_trust_policy.go:42-61` | awsgw | Parses the role trust policy with `handlers_iam.ValidateTrustPolicyDocument` | policy evaluator |
| S7 | `handlers/sts/get_session_token.go:73` | awsgw | `IAMService.GetUser` to bind the user ID at mint | role resolver. Borderline: it resolves a user, but the capability is the same canonical-principal resolution as roles. |
| S8 | `handlers/sts/session_principal.go:61-125` (`VerifySessionPrincipal`) | awsgw | `GetUser`/`GetRole` by name, compares the live immutable ID with the one stored at mint | credential verifier |
| S9 | `handlers/sts/presigned_url_verify.go:150-200` (`resolvePrincipalForVerify`), exposed over NATS by `runtime/roles/awsgw/eks_token_verify.go:29` | awsgw, for the EKS token webhook agent | `LookupAccessKey`, `DecryptSecret`, `GetUser`; session record via `LookupSessionCredential` and `handlers_iam.DecryptSecret(…, s.masterKey)` | credential verifier |
| S10 | `handlers/sts/get_access_key_info.go:73` | awsgw | `LookupAccessKey` and session lookup to map an AKID to its account | credential verifier. Borderline: needs only the AKID-to-account fact. |
| S11 | `gateway/auth.go:375-405` (`resolveLongLivedAKID`) | awsgw | `LookupAccessKey`, status, `DecryptSecret` | credential verifier |
| S12 | `gateway/auth.go:409-472` (`resolveSessionAKID`), `483-520` (`checkSessionPrincipal`) | awsgw | `LookupSessionCredential`, expiry, `VerifySessionToken`, `DecryptSecret`, then S8 | session consumer |
| S13 | `gateway/auth_accountstatus.go:67-97` (`checkAccountActive`) | awsgw | `GetAccount` status; ACTIVE cached 5 s (`accountActiveTTL`, line 16) | credential verifier. Borderline: account status gates every credential, so it is verification input rather than policy. |
| S14 | `gateway/gateway.go:771` (`principalUser`) → `gateway/sts/GetCallerIdentity.go:61` (`ResolveCallerUser`) | awsgw | `GetUser`/role lookup for `aws:userid` and the user ARN | policy evaluator. Borderline: a principal read, but its only use is condition-key input. |
| S15 | `gateway/gateway.go:809-885` (`evaluatePrincipalPolicyResources`) | awsgw | `GetUserPolicies` (`handlers/iam/service_impl.go:1997`) or `GetRolePolicies` (`handlers/iam/roles.go:579`): user, group, role and policy records; evaluates with `iampolicy.EvaluateWithKeys`; global root bypass at 827 | policy evaluator |
| S16 | `gateway/gateway.go` `checkPassRole`, callers `gateway/ec2.go:194,212,221,477`, `gateway/ecs.go:66`, `gateway/eks.go:296` | awsgw | Evaluates `iam:PassRole` on an ARN; role existence comes from S5-style lookups in the handlers | role resolver. ADR-0005 S3 scope; listed for completeness, not a projection consumer. |
| S17 | `gateway/ecr_principal.go:81-250` (`resolveECRPrincipal` and helpers) | awsgw (OCI registry path) | Rehydrates an ECR bearer token: `LookupAccessKey`, `GetUser`, `GetAccount`, `LookupSessionCredential`, S8 | credential verifier |
| S18 | `gateway/admin_createaccount.go:112,135` | awsgw | Encrypts the new account admin's secret with the IAM master key into the admin idempotency store and decrypts it on replay | bootstrap path. Borderline: it keeps a copy of an IAM secret outside the IAM store. |
| S19 | `runtime/roles/awsgw/awsgw.go:205-241, 272-286` | awsgw | Reads `master.key` raw, builds IAM and STS, wires the revoker, consumes `bootstrap.json` via `SeedBootstrap` (`handlers/iam/service_impl.go:855`) | bootstrap path |
| S20 | `daemon/eks_deps.go:27-48` (`systemRoleEnsurer`), `daemon/ecs_deps.go:39-51` | daemon | Reads `master.key` and constructs a second full `IAMServiceImpl`, which opens and migrates every IAM bucket and writes roles, instance profiles and policies | invalid private-store reader. Borderline: it runs IAM's own code, but in a non-authority process with full store access and the master key. |
| S21 | `domains/ec2/guestmetadata/nats_clients.go:42-95` | vpcd | Receives `handlers_iam.InstanceProfile` and `iam.GetRoleOutput` (including the trust document) over NATS from S3, cached 5 min (line 23); requests session credentials for guests | role resolver |
| S22 | `operator/cli/admin.go:2876-2899` (`initIAMServiceFromConfig`), callers `admin_principal.go:43,116,246,313,476`, `admin_account_delete.go:76`, `admin.go:2918,3046` | spx (**CLI**) | Connects with the cluster NATS token, reads `master.key`, builds a full `IAMServiceImpl` | bootstrap path |
| S23 | `operator/cli/admin.go:3449-3480` (`writeBootstrapFilesWithAdmin`) | spx (**CLI**) | Generates `master.key`, encrypts the system and admin secrets into `bootstrap.json`, writes plaintext `system-credentials.json` (3450) | bootstrap path |
| S24 | `operator/cli/templates/predastore.toml:79-121` | spx (**CLI**) | Renders Predastore's `[iam]` section with the NATS token, `master.key` path and `spinifex-iam-access-keys`, plus `[[auth]]` system and Northstar keys | bootstrap path |
| S25 | `accountteardown/reapers_iam.go:17-330`, used by `gateway/admin_deleteaccount.go:114` and `operator/cli/admin_account_delete.go:93` | awsgw, spx | Deletes IAM entities through `IAMService` methods only | authority. Borderline: a lifecycle caller through the authority's API, not a raw reader. |
| S26 | `handlers/iam/mock/mock.go`, `handlers/sts/*_test.go`, `operator/cli/admin_test.go:1090` | **test** | Mock `IAMService`; tests open the session bucket and assert template bucket names | bootstrap path (test support) |
`IAMService` lifecycle callers that use only the public interface and read no private record are not projection consumers: `handlers/{ecs,eks,rds,elbv2}` system-role ensurers, `domains/ec2/awsapi/{instance,spotinstance}` and the IAM AWS adapter `gateway/iam/*`.
### 2.2 Predastore
| # | Location | Process | Reads / writes | Classification |
|---|---|---|---|---|
| P1 | `predastore:internal/gate/auth/auth.go:84-152` | s3d | Duplicates `iamAccessKey`, `sessionCredential`, `iamUser`, `iamRole`, `iamGroup`, `iamPolicy` JSON shapes | invalid private-store reader |
| P2 | `auth.go:299-356` (`NewNATSIAMProvider`) | s3d | `masterkey.LoadShared(master.key)` (311); NATS with the cluster token | invalid private-store reader |
| P3 | `auth.go:624-725` (`LookupCredentials`) | s3d | Reads `spinifex-iam-access-keys/<AKID>`, checks `status`, decrypts `secret_access_key`; caches 60 s (217) with watchers (395-397) | invalid private-store reader. It is functionally a credential verifier, but every input is a private record. |
| P4 | `auth.go:457-560` (`lookupSessionCredentials`) | s3d | Reads `spinifex-iam-session-credentials/<ASIA>`, expiry, decrypts `secret_encrypted`; never cached | invalid private-store reader |
| P5 | `auth.go:764-998` (`resolveUserPolicies`, `resolveGroupPolicies`, `resolveRolePolicies`, `resolveManagedPolicies`) | s3d | Reads users, groups, roles, policies by `<account>.<name>`; reimplements group and managed-policy resolution | invalid private-store reader |
| P6 | `internal/gate/middleware.go:263-290` | s3d | `iampolicy.EvaluateWithKeys` over P5's documents | policy evaluator |
| P7 | `auth.go:158-180` (`ConfigProvider`), `auth.go:999-1029` (`ChainProvider`), `internal/gate/policy.go:19`, `middleware.go:311,326`, `internal/gate/handlers/list_buckets.go:90` | s3d | Config `[[auth]]` keys checked before IAM, returned with `SkipPolicyCheck: true` | bootstrap path |
| P8 | `predastore:internal/storetest/keys.go:13` | **test** | Deterministic test master key for shard tests, not IAM | bootstrap path (test support) |
### 2.3 Goanna
Not characterised in Phase 0; deferred (D6).
The rows below are inventory facts from code reading, not test evidence.
Goanna is a future consumer of IAM state: it currently reads the IAM buckets directly, and it must adopt the final IAM enforcement projection, including IAM identity-policy enforcement, before production use.
| # | Location | Process | Reads / writes | Classification |
|---|---|---|---|---|
| G1 | `goanna:internal/auth/provider.go:33-76` | goannad | Duplicates the access-key and session JSON shapes and bucket names | invalid private-store reader |
| G2 | `provider.go:113-170` (`NewProvider`) | goannad | `masterkey.LoadShared(master.key)` (128) | invalid private-store reader |
| G3 | `provider.go:174-226` (`lookupAccessKey`) | goannad | Reads `spinifex-iam-access-keys/<AKID>`, status, decrypts; caches 60 s (49) with a watcher | invalid private-store reader |
| G4 | `provider.go:229-260` (`lookupSession`) | goannad | Reads `spinifex-iam-session-credentials/<ASIA>`, expiry, decrypts; uncached | invalid private-store reader |
Goanna evaluates no IAM policy (no `iampolicy` import and no authorization code under `goanna:internal` or `goanna:cmd`), so it reads no policy, user, group or role record.
### 2.4 Bluebottle
Bluebottle reads no IAM state and declares no type that encodes an IAM record.
| Package | Role | Note |
|---|---|---|
| `bluebottle:pkg/iampolicy` | Policy vocabulary and evaluator (`EvaluateWithKeys`, `evaluator.go:36`) | Shared contract, used by S15, P6 and STS; `door_test.go` already fixes the condition-key sets of the gateway and Predastore doors. |
| `bluebottle:pkg/auth/arn.go` | ARN parsing (`ParseRoleARN` 39, `ResolvePolicyARN` 59) | Mechanism only. |
| `bluebottle:pkg/sigv4` | SigV4 parse and verify | `Verify` (`verify.go:24`) takes the plaintext secret; there is no verify-by-derived-signing-key entry point. |
| `bluebottle:pkg/masterkey` | AES-256-GCM key loading | The package comment describes shard encryption, but every IAM consumer uses it; `EncryptBase64`/`DecryptBase64` (135-160) pass no associated data. |
## 3. Holders of the IAM master key that read no IAM record
These are not IAM consumers, but each process holds the key that decrypts every IAM and session secret, and each uses the IAM package's helpers as its crypto boundary.
| Location | Process | Use |
|---|---|---|
| `domains/acm/store.go:224,327` | awsgw | ACM private keys via `handlers_iam.EncryptSecret`/`DecryptSecret` |
| `domains/ecr/auth/signingkey.go:179,206` | awsgw | ECR token signing key |
| `gateway/bedrock/credentials.go:66,87` | awsgw | Bedrock credentials bucket |
| `handlers/rds/bootstrap.go:105,199`, `daemon/rds_deps.go:56` | daemon | RDS bootstrap envelopes |
| `handlers/eks/oidc_keypair.go:82,116`, `nats_bootstrap.go:247,265`, `nodegroup.go:1160`, `daemon/eks_deps.go:52` | daemon | EKS OIDC signing key, join tokens, kubeconfig |
| `domains/ochre/vector/appliance.go:499,514`, `daemon/ochre_deps.go:63`, `operator/cli/admin_ochre.go:1005,1077` | daemon, spx | Ochre appliance passwords |
| `daemon/daemon.go:2003,2138`, `daemon/ecs_deps.go:39` | daemon | Service dependencies |
## 4. Current authentication flows
### 4.1 AWSGW SigV4 (`gateway/auth.go:40-290`)
| Step | Check | Failure response | Rate-limited |
|---|---|---|---|
| 1 | Per-IP lockout (51) | `RequestLimitExceeded` | n/a |
| 2 | `sigv4.Parse`: envelope, scope, skew, payload (57-97) | `MissingAuthenticationToken`, `RequestEntityTooLarge`, `InternalError` (body read), `SignatureDoesNotMatch` (skew), `IncompleteSignature` | skew and malformed only |
| 3 | Credential-scope service served (115-125) | Unknown-service error | no |
| 4 | NATS connected (129) | 503 `ServiceUnavailable` | no |
| 5a | AKIA: KV `Get` (S11) | unknown → `InvalidClientTokenId`; KV error → `InternalError` | unknown only |
| 5b | AKIA: `status == Active` | `InvalidClientTokenId` | yes |
| 5c | AKIA: decrypt secret | `InvalidClientTokenId` | yes |
| 6a | ASIA: session `Get` (S12) | KV error → `InternalError`; absent → `InvalidClientTokenId` | absent only |
| 6b | ASIA: expiry (424) | `ExpiredToken` | yes |
| 6c | ASIA: `X-Amz-Security-Token` present and HMAC matches (431-442) | `InvalidClientTokenId` | yes |
| 6d | ASIA: decrypt secret | `InvalidClientTokenId` | yes |
| 7 | Other prefix (149-154) | `InvalidClientTokenId` | yes |
| 8 | `sig.Verify` (163) | `SignatureDoesNotMatch` | yes |
| 9 | Account ACTIVE (183, S13) | not active → `AccessDenied`; read error → `InternalError` | no |
| 10 | Session principal continuity (191, S8) | replaced, gone or legacy → `InvalidClientTokenId`; fault → `InternalError` | verdicts except legacy |
| 11 | Resolve `aws:userid` (198, S14) | fault → `InternalError`; missing user → key omitted | no |
| 12 | Per action: policy resolution and evaluation (S15) | `AccessDenied`; resolution fault after 3 tries on transient NATS errors → `InternalError` | no |
Caching: none for credentials, sessions or policies; only the ACTIVE account answer for 5 s.
Backing state unavailable: the gateway's NATS connection closed fails fast with 503; a closed IAM/STS connection or a deleted access-key, session or accounts bucket gives `InternalError`, and no request proceeds (`TestCurrentBehaviour_Auth_BackingStateUnavailable`, in-process router over embedded JetStream; not exercised on a deployed cluster).
Anti-enumeration:
- An unknown AKIA, an inactive AKIA, an undecryptable secret and an unknown prefix all return `InvalidClientTokenId`, so status is not revealed.
- A known, active key with a wrong signature returns `SignatureDoesNotMatch`, so key existence is distinguishable from an unknown key; whether AWS returns the same codes is unverified (Section 5).
- Account status, principal continuity and policy reads all happen after the signature, so a caller without the secret cannot probe them (comments at 181 and 476-482).
- An ASIA key past expiry returns `ExpiredToken` (HTTP 400) before the session token or signature is checked, so anyone holding only an expired session AKID learns that it existed (Section 7 F2, D10); records are kept for one hour past expiry (`janitorGracePeriod`, `handlers/sts/session_credentials.go:35`).
- An AKIA key ignores `X-Amz-Security-Token`: the header is signed but never checked (Section 7 F1).
- Timing: an unknown AKIA returns after one KV read, a known key additionally decrypts and HMACs; the error codes already differ, so the timing difference adds nothing for unknown versus bad-signature, and unknown versus inactive both stop after the same KV read. Not measured.
### 4.2 AWSGW anonymous STS (`gateway/sts_anonymous.go:16-55`, `gateway/sts.go:81`)
Only `AssumeRoleWithWebIdentity` is anonymous.
The interceptor runs before the SigV4 middleware (`gateway/gateway.go:347`), so neither the per-IP auth lockout nor the post-auth throttle applies to it.
Order in `handlers/sts/assume_role_with_web_identity.go`:
1. Parameter validation and rejection of session policies (36-50), `ValidationError` or `MissingParameter`.
2. Role ARN syntax to obtain the role account only (55-58).
3. JWT parse, ES256 only, `exp` required (60-72); the key function (159-193) resolves `kid`, `iss`, issuer account and cluster, checks the OIDC provider registration by raw read (S4), and fetches the cluster JWKS through the EKS resolver. Every failure here, including a KV outage, returns `InvalidIdentityToken`.
4. `iss`, `sub` non-empty and `aud` contains `sts.amazonaws.com` (74-81), `InvalidIdentityToken`.
5. Role resolution (83-90): absent, foreign or non-canonical ARN → `AccessDenied`.
6. Duration bound against the role's `MaxSessionDuration` (92-104), `ValidationError`.
7. Trust policy evaluation (114), then mint (119).
The token is verified before any role read, so an unauthenticated caller cannot probe role existence.
A caller holding a valid token from a registered issuer can distinguish an existing role (step 6 `ValidationError` for an over-long duration) from an absent one (step 5 `AccessDenied`); whether AWS orders these checks the same way is unverified.
No caching: every call reads the provider record and the JWKS.
`GetAccessKeyInfo` (S10) is signed but needs no permission and returns the account for any AKIA or ASIA key it holds.
### 4.3 Predastore S3 SigV4 (`predastore:internal/gate/middleware.go:214-330`)
1. Unsigned request: allowed through only to a public bucket's permitted methods, else `AccessDenied`.
2. `sigv4.Parse` errors map in `internal/gate/handlers/respond.go:20-100`.
3. `ChainProvider.LookupCredentials` (P7): config `[[auth]]` keys first, then NATS IAM.
4. AKIA (P3): cache hit returns at once; else KV read, status, empty-account check, decrypt, then user, group and policy resolution (P5) before the signature is verified.
5. ASIA (P4): KV read, expiry, decrypt, then user or role policy resolution; no session-token check and no principal-continuity check.
6. `sig.Verify` (250), then `EvaluateWithKeys` unless `SkipPolicyCheck` (263-290), then bucket ownership (299-322).
| Condition | Response |
|---|---|
| Unknown AKID, or session absent or expired, or a session principal deleted | 403 `InvalidAccessKeyId` |
| Inactive AKIA (685), undecryptable secret, empty account, deleted user behind an AKIA, any KV fault | 500 `InternalError` |
| Malformed or foreign attached policy ARN (`ErrPrincipalConfig`) | 403 `AccessDenied` |
| Wrong signature | 403 `SignatureDoesNotMatch` |
| Policy deny or cross-account bucket | 403 `AccessDenied` |
| IAM buckets not yet created | 403 `InvalidAccessKeyId` (bootstrap) |
Caching: AKIA results for 60 s, invalidated by watchers on the access-key, user, policy and group buckets but not roles; a watcher that dies flushes once and then leaves the 60 s TTL as the only bound.
Sessions are never cached.
Backing state unavailable: KV faults give 500; config keys keep working because they are checked first.
Anti-enumeration: an inactive key (500) is distinguishable from an unknown key (403) before any signature check, and policy resolution runs for any presented AKIA before the signature, so an unauthenticated caller who knows a key ID can drive user, group and policy reads.
No account-status check exists in Predastore.
### 4.4 Goanna CloudWatch SigV4 (`goanna:internal/auth/middleware.go:42-100`)
From code reading only; not characterised in Phase 0; deferred (D6).
1. `sigv4.Parse`; envelope errors map in `writeParseError` (104-128).
2. Credential-scope service must be `monitoring` (59-66).
3. `Provider.Lookup` (68): AKIA via G3 or ASIA via G4.
4. `signed.Verify` (74), `SignatureDoesNotMatch` on failure.
5. The account is attached to the context; no policy or account-status check follows.
Unknown, inactive, empty-account and undecryptable keys all return 403 `InvalidClientTokenId`; an expired session returns 403 `ExpiredToken` before signature verification; a KV fault returns 503 `ServiceUnavailable`.
Caching: AKIA 60 s with a watcher on the access-key bucket; sessions uncached.
No session-token check and no principal-continuity check.
## 5. AWS reference points used above
Each claim cites an official AWS documentation page or is marked unverified; citations are listed in the design document, Section 12.
- Unknown key versus wrong signature: the wire codes AWS returns (`InvalidClientTokenId` or `InvalidAccessKeyId` versus `SignatureDoesNotMatch`) are unverified; the STS *Common Error Types* page (https://docs.aws.amazon.com/STS/latest/APIReference/CommonErrors.html) lists neither code.
- Key-ID existence: AWS documents `sts:GetAccessKeyInfo` as returning the account for an access key ID, and states that it does not indicate whether the key is active, inactive or deleted (*GetAccessKeyInfo*, https://docs.aws.amazon.com/STS/latest/APIReference/API_GetAccessKeyInfo.html).
- Inactive key: unverified.
- Expired session: the STS *Common Error Types* page lists `ExpiredTokenException` with HTTP 403; the Query-protocol code and status, and whether expiry is checked before or after the token and signature, are unverified.
- An ASIA key used without its session token: unverified.
- An AKIA key sent with `X-Amz-Security-Token`: unverified.
- `aws:userid`: the account ID for the account root user and the unique ID for an IAM user (*IAM policy elements: Variables and tags*, "Principal key values", https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_variables.html#principaltable).
## 6. Coverage gaps in this inventory
- The AWSGW flow (4.1) is pinned by the tests in `gateway/auth_current_behaviour_test.go`, which drive the real router over real IAM and STS services on an embedded JetStream; no flow was exercised on a deployed cluster.
- Predastore (4.3) and Goanna (4.4) flows are from code reading; Goanna is deferred (D6) and not characterised in Phase 0.
- The ECR registry path (S17), STS presigned verify (S9) and `GetAccessKeyInfo` (S10) are not covered by the Phase 0 tests.
- NATS subject ACLs: comments call the IMDS subjects "ACL-gated" (`handlers/iam/imds_rpc.go:13`), but the rendered `operator/cli/templates/nats.conf:41-44` defines one shared token with no per-user permissions; deployed NATS configuration was not inspected.
- Northstar, Viperblock and `spinifex/spinifex/agents` were searched for the bucket names and record field names and none reads IAM state; `viperblock/internal/predastoretest/server.go:131` only renders a Predastore test config.
- Viperblock loads its volume key with `masterkey.LoadShared` (`viperblock/viperblock/masterkey_load.go:25`) from a configured path; whether a deployment points that path at the IAM `master.key` was not checked.
## 7. Characterisation findings (AWSGW, current behaviour)
Each finding states what the named test in `gateway/auth_current_behaviour_test.go` proves about current behaviour, and nothing beyond it.
**F1. AKIA credentials with a session-token header are accepted.**
A long-lived AKIA key sent with an `X-Amz-Security-Token` header (here, another session's valid token) authenticates as the key's IAM user; the header is covered by the signature but never checked.
Test: `TestCurrentBehaviour_Session_AssumeRoleCredentials/long_lived_with_session_token`.
AWS behaviour for this request is unverified, so this is recorded as current behaviour only, with no change proposed.
**F2. Session expiry is reported before token validation and SigV4 proof.**
An expired ASIA session answers `ExpiredToken` (HTTP 400 today) whether the request carries the right token, no token, or a tampered token with a wrong secret; with no token, the door decrypts no secret before answering.
A caller holding only the ASIA key ID therefore learns that an expired session existed.
Tests: `TestCurrentBehaviour_Session_Expired` (`valid_token`, `no_token`, `wrong_token`) and `TestCurrentBehaviour_AntiEnumeration_RejectionShapes/session_expired_no_token`.
Target ordering: D10.
**F3. A tenant IAM user named `root` is presented with the account ID as its UserId.**
`GetCallerIdentity` for a tenant user named `root` returns `UserId` (the `aws:userid` value) equal to the account ID instead of the AIDA ID stored on its user record; the ARN is the user's own ARN.
AWS uses the account ID as `aws:userid` only for the account root user (Section 5).
This is an identity-presentation issue: the same user with no policy is implicitly denied `iam:ListUsers`, so it does not take the global-root policy bypass for that action.
Any effect on policies that test `aws:userid` in a condition is untested.
Tests: `TestCurrentBehaviour_Auth_LongLivedKeyResolvesPrincipal/tenant_user_named_root`, `TestCurrentBehaviour_Policy_IdentityPolicyOutcomes/tenant_user_named_root_implicit_deny`.
**F4. An orphaned access key authenticates as, and is authorized as, a same-name replacement user.**
The tests construct the orphaned state directly by rewriting the user record so it no longer lists the key; the access-key record stays `Active`.
They prove that the key still authenticates, that `DeleteUser` then succeeds, that after a same-name user is recreated the key authenticates as the new user (new `UserId`), and that the policy check applies the replacement user's policy: the key is allowed `iam:GetUser`, which only the replacement's policy grants, and denied `iam:ListUsers`, which only the original user's policy granted.
How production reaches the orphaned state is INFERRED from code: `CreateAccessKey` and `DeleteAccessKey` each make two non-transactional writes (`handlers/iam/service_impl.go:665-683`, `757-770`), and an interruption between them would leave the key unlisted; no failure-injection test proves this.
Tests: `TestCurrentBehaviour_Revocation_OrphanedKeyAuthenticatesAsRecreatedUser`, `TestCurrentBehaviour_Revocation_OrphanedKeyPermissionsAfterRecreation`.
Design consequence: P23.
**F5. A GetSessionToken session outlives its minting key.**
Deactivating and then deleting the long-term key that minted a GetSessionToken session leaves the session authenticating as the IAM user.
Test: `TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey`.
Open question: D11.
@@ -0,0 +1,365 @@
# IAM enforcement projection: v1 contract design
**Status:** Draft for review; contract design only, nothing implemented.
This is the minimum versioned Bluebottle IAM enforcement projection required by ADR-0005 S1 and S6(2), designed from the consumer inventory in `iam-enforcement-inventory.md` (cited below as S*n*, P*n* and G*n* rows).
ADR-0005 and ADR-0006 are accepted and are cited as decisions; nothing here changes either.
Clauses are numbered P1, P2… and are appended, never renumbered.
Decisions are D1 to D12, each recorded once in the decision register (Section 14); a D-number anywhere else in either document means that register entry and nothing more.
**Phase 1** means the first step of the code stack in Section 13; Phase 0 is the inventory and the AWSGW characterisation tests in `gateway/auth_current_behaviour_test.go`.
Spinifex paths are relative to `spinifex/spinifex/`; other repos are prefixed as in the inventory.
## 1. Purpose and standing
P1. The projection is the set of signed, versioned facts a local enforcement point needs to verify a SigV4 credential and authorize a request without reading IAM persistence (ADR-0005 S1 "first required shared identity boundary").
P2. Its semantic owner is the Spinifex IAM authority (today `handlers/iam` with `handlers/sts` for sessions; `domains/iam` once extracted).
Bluebottle owns only its vocabulary, evaluation semantics and conformance fixtures (Section 10).
P3. "Minimum" means every fact below has a named consumer in the matrix (Section 11), and no fact is included for a consumer that does not need it.
## 2. Non-goals
| Non-goal | Consequence |
|---|---|
| Moving `handlers/iam`, `handlers/sts` or IAM records | Records, keys, buckets and migrations stay where they are; the projection is produced from them. |
| A broad `IAMService` replacement | Consumers get the projection or a narrow verification capability, never a wider service (ADR-0005 S6 last paragraph). |
| Raw-KV, global-singleton or permissive fallback | No consumer may fall back to reading `spinifex-iam-*`, to a process-global credential table, or to allow-on-error while the projection is absent or stale. |
| Predastore migration or production auth change | This document changes no code; Predastore's current flow (inventory 4.3) stays until its migration PR (Section 13). |
| ECS/EKS `iam:PassRole` migration | ADR-0005 S3 and S6(6) work; role resolution is not a projection fact (P9). |
| Transport, storage and key-custody design | Stated as responsibilities only (Section 10). |
| EKS OIDC discovery | ADR-0005 S4, planned in `sts-oidc-discovery-projection-plan.md`. |
## 3. Facts
P4. Every fact carries a header: `contract` (`"mulga.iam.enforcement"`), `major` (`1`), `kind`, `key`, `generation` (per-key, strictly increasing), `rev` (authority-wide sequence at which it was emitted), `issuedAt` (authority clock), `critical` (list of required extension names, normally empty) and `sig` (Section 4).
P5. **AccountFact**, key `accountID`.
Fields: `status` (`ACTIVE`, `SUSPENDED`, `TERMINATING`).
Consumers: AWSGW (S13, S17) and Predastore (D7); Goanna as a deferred future consumer (D6, D7).
P6. **PrincipalFact**, key `(accountID, principalID)` where `principalID` is the immutable IAM unique ID (`AIDA…` user, `AROA…` role).
Fields: `type` (`user`, `role`), `arn` (stored ARN including path), `name`, `status` (`present`, `deleted`), `policyGeneration`.
A role-session principal is not a PrincipalFact; it is described by its CredentialFact and refers to its role's PrincipalFact.
Consumers: AWSGW (S8, S14), Predastore (P4, P5), STS presigned verify (S9).
P7. **CredentialFact**, key `accessKeyID`.
Fields: `kind` (`long-term`, `session`), `accountID`, `principalID` (the user or role it was issued for), `status` (`Active`, `Inactive`, `Deleted`), `issuedAt`, `expiresAt` (sessions only), `sessionName` and `assumedRoleArn` (role sessions only, for `aws:userid` and caller ARN), `sessionTokenDigest` (sessions only, P14), `verifier` (Section 4.2), and optional `sourceCredentialID` (GetSessionToken sessions only: the long-term AKID that minted the session).
`sourceCredentialID` is informational in major 1: no consumer enforces it, and it does not change revocation behaviour until D11 is decided.
Consumers: AWSGW (S11, S12, S17), Predastore (P3, P4), STS (S9, S10); Goanna (G3, G4) as a deferred future consumer (D6).
P8. **PolicySetFact**, key `(accountID, principalID)`.
Fields: `policyGeneration` and `documents`, the ordered list of `iampolicy.PolicyDocument` that IAM resolves for that principal today (user inline, attached managed default versions, group inline and group managed; or role inline and attached managed), exactly the set `GetUserPolicies` and `GetRolePolicies` return (`handlers/iam/service_impl.go:1997`, `handlers/iam/roles.go:579`).
Resolution, including the missing-group and unmodelled-AWS-managed-policy rules, happens once at the authority; consumers never resolve groups or managed ARNs (ADR-0005 S1 requires Predastore to stop reimplementing group resolution).
A principal whose policies cannot be resolved is published with `documents` absent and `unresolvable: true`, and every consumer treats it as Malformed (P24), never as an empty grant.
Consumers: AWSGW (S15), Predastore (P5, P6); Goanna once it adopts the projection, because it must then enforce IAM identity policy (D6).
P9. Excluded facts and why:
| Excluded | Reason |
|---|---|
| Role trust policies, `MaxSessionDuration` | Only STS issuance (S5, S6) needs them, and STS sits with the authority; a role resolver capability serves it (ADR-0005 S2). |
| Instance profiles | Served to vpcd by the existing RPC (S3, S21); not an enforcement input. |
| OIDC provider records | STS-only (S4); a narrow IAM capability, not a broadcast fact. |
| Tags, paths beyond the ARN, creation times, policy versions other than default | No enforcement consumer reads them. |
| Group facts | Folded into PolicySetFact (P8). |
## 4. Integrity and secret handling
### 4.1 Authenticity
P10. Every fact is signed by the IAM authority with an Ed25519 signature (D2) over its canonical encoding, which includes every header field in P4.
Consumers hold only the authority's public verification keys, identified by `keyID`, from their own configured trust bundle.
A fact whose signature does not verify, whose `keyID` is unknown, or whose canonical encoding does not round-trip is Malformed (P24).
P11. The signature, not transport access, is the trust root.
Today any holder of the shared NATS token can write IAM buckets (inventory 6); a signed fact cannot be forged by a consumer that can write the transport.
P12. Replay: a consumer accepts a fact for a key only if its `generation` is greater than the one it holds, so an old Active fact cannot be replayed over a later Inactive or Deleted one.
Freshness (Section 5) bounds replay of a whole stale view.
### 4.2 Credential verification material
P13. SigV4 is a symmetric HMAC scheme, so verifying it needs key material derived from the secret.
The projection never carries the long-term secret or the session secret, in plaintext or under the master key.
Under D1 (Accepted in principle), the `verifier` carries SigV4 derived signing keys, `kSigning = HMAC chain(secret, date, region, service)`, one per (scope date, region, service) the consumer is entitled to, each encrypted to that consumer's own public key (audience-bound), and covered by the fact signature.
A derived signing key is a sensitive signing capability, not a public verifier: its holder can sign any request within its date, Region and service scope.
A Predastore view receives only `s3` keys for its Region (and a future Goanna view only `monitoring` keys), so a compromised consumer can forge requests only for its own service, Region and the dates it holds.
Recipient-key rotation and scope issuance need implementation review before this material is used (D1 gate).
Dates published: the current and next UTC date for header-signed requests; for presigned URLs, every date back to the consumer's maximum presign age (`bluebottle:pkg/sigv4/sigv4.go:23` sets 7 days) and never earlier than the credential's `issuedAt`.
Bluebottle's `sigv4` needs a verify-by-signing-key entry point for this; today `Verify` takes only the plaintext secret (`bluebottle:pkg/sigv4/verify.go:24`).
P14. Session tokens: `sessionTokenDigest` is SHA-256 of the wire token.
Tokens are 32 random bytes (`handlers/sts/assume_role.go:68`), so an unkeyed digest cannot be inverted or brute-forced, and consumers need no shared HMAC key.
The authority keeps its existing master-key HMAC in its private record; the digest is an additional projection field.
P15. Must never appear in the projection: long-term or session secrets in any encoding; the master key or any key-encryption key; master-key ciphertexts (`secret_access_key`, `secret_encrypted`); session-token plaintext or the master-key HMAC; the authority's signing private key; IAM bucket names, KV keys or persistence record encodings; trust policies; bootstrap credentials.
## 5. Freshness
P16. The authority emits a signed **Watermark** fact at a fixed interval H = 5 s (D3) carrying `rev` (the highest `rev` emitted) and `issuedAt`.
P17. A consumer's view is **current** when it has applied every fact up to the last watermark's `rev`, that watermark was received within S of the consumer's monotonic clock, and its `issuedAt` is within S plus the declared maximum clock skew of the consumer's wall clock.
S is the staleness bound: 30 s normally, set per consumer profile, with a hard maximum of 60 s (D3; 60 s is today's Predastore AKIA cache TTL, inventory P3).
P18. A view is built from a signed **Snapshot** (all live facts at a `rev`, terminated by a watermark at that `rev`) followed by incremental facts.
Absence of a key in a current view means NotFound only if the view was built from a complete snapshot; a consumer that has not completed its first snapshot is Unavailable (P24).
P19. When stale, a consumer refuses every request that depends on the projection (P26), and never extends a cached answer past S.
## 6. Revocation
P20. Access-key deactivation or deletion, principal deletion, account suspension and session revocation each publish a new fact generation (`Inactive`, `Deleted`, `deleted`, `SUSPENDED`) before the authority's API call returns success.
Effect at every current consumer is bounded by S plus one watermark interval H.
P21. Session revocation by principal deletion (`RevokeUserSessions`, `RevokeRoleSessions`, `handlers/sts/session_credentials.go:201,212`) publishes `Deleted` CredentialFacts for every affected session, and the principal's own `deleted` PrincipalFact independently invalidates any session the sweep misses (P23 check 3).
This clause describes how today's delete-the-record mechanism would be projected; whether that mechanism stays or is replaced by an AWS-style deny on token issue time is D12 (Open), and who publishes session-validity facts is D9 (Open).
A GetSessionToken session today outlives deletion of its source access key (`TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey`); whether it should is D11 (Open).
P22. Deleted-key and deleted-session facts are retained as tombstones until the next snapshot; a consumer that misses a tombstone recovers by resynchronising to a snapshot in which the key is absent.
## 7. Replacement and identity reuse
P23. All facts bind to immutable IDs, never names.
A consumer accepts a credential only if:
1. the CredentialFact is `Active` and, for sessions, unexpired and its token digest matches;
2. its `accountID` has an `ACTIVE` AccountFact;
3. its `principalID` has a `present` PrincipalFact in the same account; and
4. it evaluates the PolicySetFact keyed by that same `principalID`.
A user deleted and recreated under the same name receives a new `principalID`, so the old key or session fails check 3 and can never reach the new user's PolicySetFact.
AKIDs are never reused by the authority.
Current-code evidence that this is a change, not a restatement:
- The long-term `AccessKey` record holds only `user_name`, not the user ID (`handlers/iam/types.go:68-75`), and every verifier resolves the user by name (`gateway/auth.go:375-405`, S9, S17, P3).
- `DeleteAccessKey` removes the key from the user record before deleting the access-key record (`handlers/iam/service_impl.go:757-770`), and `CreateAccessKey` writes the key before the user record (665-683); the two writes are not transactional.
That an interruption between them leaves an `Active` access-key record the user no longer lists is INFERRED from the code; no failure-injection test interrupts either call.
- The resulting state is reproduced directly: `TestCurrentBehaviour_Revocation_OrphanedKeyAuthenticatesAsRecreatedUser` rewrites the user record so it no longer lists the key, and shows that the key still authenticates, that `DeleteUser` then succeeds because it checks only the user's list (585), and that after a same-name user is recreated the key authenticates as the new user (new `UserId`, same ARN).
- `TestCurrentBehaviour_Revocation_OrphanedKeyPermissionsAfterRecreation` shows the policy consequence through the real router and dispatcher policy check: after recreation, the orphaned key is allowed an action only the replacement user's policy grants (`iam:GetUser`) and denied the action only the original user's policy granted (`iam:ListUsers`).
The orphaned key therefore receives the replacement user's permissions and loses the original user's.
- Session records already bind `RoleID`/`UserID` and AWSGW re-checks them (S8, pinned by `TestCurrentBehaviour_Revocation_UserDeletedAndRecreated` and `TestCurrentBehaviour_Revocation_RoleDeletedAndRecreated`); Predastore does not (P4), from code reading.
The producer PR therefore needs an expand migration (ADR-0006 S4) adding the user ID to every `AccessKey` record before it can publish P7's `principalID` for long-term keys.
## 8. Failure classification
P24. Each consumer classifies a lookup into exactly one class.
The AWSGW column is pinned by the tests named in inventory Section 7; the Predastore column is from code reading (inventory 4.3).
Goanna is not characterised in Phase 0 and has no column (D6).
| Class | Meaning | Fail-closed | AWSGW today | Predastore today | Target wire response |
|---|---|---|---|---|---|
| NotFound | Current, complete view has no fact for the AKID | yes | 403 `InvalidClientTokenId` | 403 `InvalidAccessKeyId` | unchanged |
| Invalid | Key `Inactive`/`Deleted`; principal deleted or replaced; account not ACTIVE before signature; token digest mismatch or missing | yes | `InvalidClientTokenId` (account: `AccessDenied` after signature) | inactive: 500 `InternalError`; no token or principal check | same code as NotFound; account check stays after the signature |
| Expired | Session past `expiresAt` | yes | 400 `ExpiredToken`, before the session token and the signature are checked | 403 `InvalidAccessKeyId` | `ExpiredToken`, only after the token digest matches and the SigV4 proof verifies (D10, P25) |
| Denied | Policy evaluation or ownership denies | yes | `AccessDenied` | 403 `AccessDenied` | unchanged |
| Unavailable | No completed snapshot, or transport down | yes | 503 `ServiceUnavailable` (gateway NATS down) or `InternalError` | 500 `InternalError` | retryable 503 `ServiceUnavailable` (D5) |
| Stale | View older than S (P17) | yes | n/a | n/a (60 s cache) | as Unavailable (D3, D5) |
| Malformed | Bad signature, unknown `keyID`, unknown `major`, unknown `critical` entry, `unresolvable` policy set | yes, and alarms | `InternalError` | `ErrPrincipalConfig` → 403 `AccessDenied`, else 500 | 500 `InternalError`; never reported as NotFound |
P25. Ordering preserved from AWSGW (inventory 4.1): NotFound and Invalid share one code, and checks that depend on mutable principal or account state run after the signature.
Ordering changed from AWSGW (D10): Expired is reported only after both the session token digest matches and the SigV4 proof verifies, so a caller holding only an ASIA key ID no longer learns that an expired session existed.
AWS's own ordering of these checks is unverified, so D10 is a target chosen on its own merits, not an AWS-parity claim.
Policy facts are evaluated only after the signature verifies, so an unauthenticated caller cannot drive policy evaluation (Predastore today resolves policies before the signature, inventory 4.3).
P26. No class other than a verified Allow lets a request proceed, and no consumer may answer from a fact older than S.
## 9. Compatibility (ADR-0006)
P27. `major` is explicit on every fact; a receiver rejects an unknown major as Malformed and changes no state (ADR-0006 S3).
Additive optional fields stay on major 1 and are ignored by receivers that do not know them; a new field that changes enforcement meaning is listed in `critical`, and a receiver that does not know a listed name treats the fact as Malformed.
P28. AWSGW, STS and the IAM authority are one coordinated Regional release: N and N-1 interoperate in both rollout orders (ADR-0006 S2).
P29. Predastore, and Goanna once it adopts the projection (D6), are independently released: each declares the projection majors it supports in its release notes, and the authority publishes the oldest major any registered consumer has proved, through an authenticated capability declaration, not a version-like field or timing (ADR-0006 S3).
An AWSGW restart or upgrade does not require a Predastore restart while their declared major is still published (ADR-0006 S2 example).
P30. An incompatible change is a successor contract `major: 2` published alongside major 1 until every consumer has proved major 2; major 1 retires through ADR-0006 S5 finalisation with operator notice.
## 10. Ownership boundary
| Concern | Owner | Not owned by |
|---|---|---|
| Fact types, canonical encoding, signature verification, freshness predicate, failure classes, `iampolicy` evaluation semantics, verify-by-signing-key, conformance fixtures | Bluebottle (new package beside `pkg/iampolicy`) | — |
| Producing facts, signing key custody and rotation, snapshot and watermark emission, revocation publication, the access-key ID migration | Spinifex IAM authority | Bluebottle |
| Transport (stream or KV layout, subjects, NATS permissions), view storage and caching, per-consumer encryption keys | The IAM authority (publish side) and each consumer (subscribe side) | Bluebottle |
| Request-specific authorization (S3 ownership, service conditions) | Each consumer | Bluebottle, IAM |
P31. Bluebottle does not import Spinifex, hold deployment state, open NATS, name a bucket or subject, hold a credential or run a service.
Fixture signing uses a test-only key generated in the fixtures; production signing lives only in the authority.
P32. Transport must give consumers read-only access to the projection channel only, separate from the IAM private buckets; how is the authority's design, constrained by P11.
## 11. Consumer matrix
| Consumer | Needs from projection | Currently reads directly | Must stop reading | Failure behaviour required | Migration blockers |
|---|---|---|---|---|---|
| AWSGW SigV4 ingress | Account, Principal, Credential, PolicySet, unless D4 (Open) permits an in-process synchronous path while co-located with the authority | `gateway/auth.go:375-520`, `gateway/auth_accountstatus.go:67`, `gateway/gateway.go:771,809` via `IAMService`/`STSService` | `LookupAccessKey`, `DecryptSecret`, `LookupSessionCredential`, `GetUserPolicies`, `GetRolePolicies`, `GetAccount` on the auth path | P24 table; NATS-down 503 preserved | D4 decided; AKID user-ID migration (P23); the audit and context-key boundary still in `gateway` (`PACKAGE_BOUNDARY_MIGRATION.md`, ingress pipeline debt) |
| AWSGW ECR registry path | Account, Principal, Credential | `gateway/ecr_principal.go:97-250` | Same reads as above | 401 Bearer for Invalid, 503 for Unavailable or Stale (present split, `classifyIAMLookupErr` 67) | Same as AWSGW; not covered by the Phase 0 tests |
| STS presigned verify and `GetAccessKeyInfo` | Credential (verifier for `sts`), Principal | `handlers/sts/presigned_url_verify.go:150-200`, `get_access_key_info.go:73` | Raw `DecryptSecret(…, s.masterKey)` and `LookupAccessKey` | `InvalidIdentityToken`/`ExpiredToken` as today; Unavailable is an error, never a token verdict | STS extraction (ADR-0005 S6(4)); whether STS or the IAM authority publishes session CredentialFacts is D9 (Open) |
| Predastore | Account (D7), Principal, Credential (verifier for `s3`), PolicySet | `predastore:internal/gate/auth/auth.go:84-998` (P1-P5) | Every `spinifex-iam-*` bucket, `master.key` for IAM, duplicated record structs, group resolution | P24 table; inactive key → 403 not 500; token digest and principal checks added; policy only after signature | Independently released (P29); config `[[auth]]` keys (P7) may not override IAM or bypass ownership or policy (D8), so any bootstrap exception needs a separately designed, bounded bootstrap principal; Predastore characterisation tests |
| Goanna (future, deferred: D6) | Credential (verifier for `monitoring`), Principal, Account (D7), PolicySet | `goanna:internal/auth/provider.go:33-260` (G1-G4), code reading only | Both IAM buckets and `master.key` | P24 table, plus IAM identity-policy enforcement it does not have today | Deferred (D6); not characterised in Phase 0; before production use it must adopt this projection and enforce IAM identity policy, not keep its account-only authentication |
| Daemon system-role ensurer | None: it writes IAM, so it needs an IAM lifecycle capability, not the projection | `daemon/eks_deps.go:27-48`, `daemon/ecs_deps.go:39-51` | Second `IAMServiceImpl` and `master.key` | Fail closed instead of static-credential fallback (ADR-0005 S5, Q-25) | ADR-0005 S6(3) capability extraction |
| vpcd guest metadata | None: role resolution over the existing RPC | `domains/ec2/guestmetadata/nats_clients.go:42-95` | `handlers_iam.InstanceProfile` type over NATS | unchanged | ADR-0005 S6(3) |
## 12. AWS parity
Each AWS claim cites an official AWS documentation page or is marked unverified.
Citations:
- [AWS1] *Troubleshoot IAM*, section "Changes that I make are not always immediately visible", https://docs.aws.amazon.com/IAM/latest/UserGuide/troubleshoot_general.html#troubleshoot_general_eventual-consistency
- [AWS2] *GetAccessKeyInfo*, AWS STS API Reference, https://docs.aws.amazon.com/STS/latest/APIReference/API_GetAccessKeyInfo.html
- [AWS3] *Common Error Types*, AWS STS API Reference, https://docs.aws.amazon.com/STS/latest/APIReference/CommonErrors.html
- [AWS4] *IAM identifiers*, section "Unique identifiers", https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html#identifiers-unique-ids
- [AWS5] *Revoke IAM role temporary security credentials*, https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html
- [AWS6] *IAM policy elements: Variables and tags*, table "Principal key values", https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_variables.html#principaltable
| Behaviour | What AWS does | This design | Status of the AWS claim |
|---|---|---|---|
| Propagation of IAM changes | IAM is eventually consistent; changes take time to become visible from all endpoints, and caching can add time [AWS1] | Bounded by S + H (P20); deliberately stricter | Documented [AWS1]; that page states no numeric bound |
| Access-key deactivation or deletion | Unverified | Bounded by S + H (P20) | Unverified |
| Unknown key vs wrong signature | Wire codes unverified ([AWS3] lists neither `InvalidClientTokenId` nor `SignatureDoesNotMatch`); `GetAccessKeyInfo` returns the account for an access key ID and does not indicate whether the key is active, inactive or deleted [AWS2] | Preserved (P25) | `GetAccessKeyInfo` documented [AWS2]; error codes unverified |
| Inactive vs unknown key | Unverified | Same code (P24) | Unverified |
| Expired session | [AWS3] lists `ExpiredTokenException` with HTTP 403; the Query-protocol code, its status and its ordering against token and signature checks are unverified | `ExpiredToken` only after token and SigV4 proof (D10, P25) | Partly documented [AWS3]; ordering unverified |
| Role session after the role's policies change | Unverified | PolicySetFact is keyed by role `principalID` and updated in place | Unverified |
| User deleted and recreated with the same name | A recreated user receives a different unique ID from the deleted one [AWS4] | Credentials and sessions of the old principal fail (P23) | Unique-ID rule documented [AWS4]; behaviour of an old key or session against a recreated user or role is unverified |
| Revoking active role sessions | IAM attaches an inline deny policy (`AWSRevokeOlderSessions`) to the role, conditioned on `aws:TokenIssueTime` [AWS5] | Undecided: today sessions are deleted (P21); D12 is Open | Documented [AWS5] |
| GetSessionToken session after its source key is deleted | Unverified | `sourceCredentialID` recorded, not enforced (P7, D11) | Unverified |
| AKIA credentials sent with `X-Amz-Security-Token` | Unverified | Not addressed; current behaviour recorded in inventory Section 7 | Unverified |
| `aws:userid` value | Account root user: the account ID; IAM user: the user's unique ID [AWS6] | PrincipalFact carries the immutable `principalID` (P6) | Documented [AWS6] |
| Suspended or closed account | Unverified | AccountFact enforced by every production consumer (D7) | Unverified |
| CloudWatch authorization | Unverified | Goanna must enforce identity policy before production use (D6) | Unverified |
## 13. Later code stack
Each step is its own PR, in order; none starts before ADR-0005 S6(1) characterisation tests for its consumer have landed.
Phase 1 begins at step 1, and it may not begin while D4 or D9 is Open.
| # | Change | Entry criteria | Exit criteria |
|---|---|---|---|
| 1 | Bluebottle: projection v1 types, canonical encoding, Ed25519 verify, freshness predicate, failure classes, `sigv4` verify-by-signing-key, conformance fixtures (valid, tampered, wrong key, unknown major, unknown critical, stale, replayed generation, replaced principal, expired, missing token, unresolvable policy set) | This design accepted; D4 and D9 decided | Fixtures pass in Bluebottle; AWSGW and Predastore test suites both run the fixtures without importing Spinifex (ADR-0005 evidence 2); no Spinifex import in Bluebottle |
| 2 | Spinifex IAM authority: add user ID to `AccessKey` (expand, migrate per ADR-0006 S4); produce, sign and publish snapshot, incremental facts and watermarks; signing key custody; publication on every revocation path | PR 1 released; transport design reviewed; D1 gate met (recipient-key rotation and scope issuance reviewed); D12 decided before any change to session revocation | For the characterisation corpus, the projection's decision equals `IAMService`'s on every case; revocation visible within S + H in a test; no consumer changed |
| 3a | AWSGW ingress and ECR path consume the projection, or the in-process path if D4 permits it | PR 2 deployed; AWSGW characterisation green | No `LookupAccessKey`, `DecryptSecret`, session lookup or policy resolution on the auth path; ADR-0005 evidence 3 tests pass |
| 3b | STS presigned verify and `GetAccessKeyInfo` consume the projection | 3a merged | No raw master-key decrypt in `presigned_url_verify.go` |
| 3c | Predastore consumes the projection | PR 2 published at a major Predastore declares; Predastore characterisation green; D8 bootstrap principal designed if any exception is kept | No `spinifex-iam-*` read, IAM struct copy or IAM `master.key` load; no `[[auth]]` override of IAM; ADR-0005 evidence 7 tests pass; compatibility matrix per P29 |
| 3d | Goanna consumes the projection | D6 deferral lifted; Goanna characterisation tests written and green | No IAM bucket or IAM `master.key` access; IAM identity policy enforced on every action; account status enforced (D7) |
Removing the daemon's second `IAMServiceImpl` (inventory S20) and the operator CLI's direct construction (S22) belongs to ADR-0005 S6(3) and S6(5) capability work, not this stack.
## 14. Decision register
Each entry stands alone.
Status values: Accepted, Accepted in principle (direction fixed, implementation gated), Rejected, Deferred, Open (not decided).
An implementation gate names what must exist or be reviewed before code that depends on the decision lands.
### D1: SigV4 verification without the long-lived secret
- **Question:** How does a consumer verify a SigV4 signature without holding the long-lived or session secret access key?
- **Status:** Accepted in principle.
- **Decision:** The CredentialFact `verifier` carries SigV4 derived signing keys, each scoped to one date, Region and service, and each encrypted to the receiving consumer's own public key (audience-encrypted).
Derived signing keys are sensitive signing capabilities: a holder can sign any request within the key's date, Region and service scope, so they are protected and handled as secrets, never as public verification material.
- **Rationale:** SigV4 is a symmetric HMAC scheme, so verification needs key material derived from the secret; date, Region and service scoping limits a compromised consumer to forging requests for its own service, Region and held dates, and audience encryption stops one consumer reading another's keys.
- **Implementation gate:** Recipient-key rotation and scope issuance (which dates, Regions and services each consumer receives) need implementation review before any derived key is issued or used.
Bluebottle `sigv4` needs a verify-by-signing-key entry point (`bluebottle:pkg/sigv4/verify.go:24` takes only the plaintext secret today).
- **Links:** P13, P15, P7; inventory 2.4 (`bluebottle:pkg/sigv4`), S11, S12, P3, P4.
### D2: Integrity of projection facts
- **Question:** How are projection facts protected against forgery and tampering by any party that can write the transport?
- **Status:** Accepted.
- **Decision:** Every fact is signed with Ed25519 by the IAM authority over its canonical encoding, including every header field; consumers hold only the authority's public verification keys from their own trust bundle.
- **Rationale:** Any holder of today's shared NATS token can write the IAM buckets (inventory Section 6), so transport access cannot be the trust root; an asymmetric signature means a consumer that can verify facts cannot forge them.
- **Implementation gate:** Signing-key custody and rotation at the authority, and the trust-bundle distribution to consumers, are reviewed before the producer (Section 13 step 2) publishes.
- **Links:** P4, P10, P11, P12, P31; inventory Section 6.
### D3: Freshness bounds
- **Question:** What heartbeat interval and staleness bounds define a current projection view?
- **Status:** Accepted.
- **Decision:** Heartbeat (watermark interval) H = 5 s; normal staleness bound S = 30 s; hard maximum S = 60 s, which no consumer profile may exceed.
- **Rationale:** Revocation then takes effect at every current consumer within S + H; 60 s matches today's longest IAM cache in a consumer (Predastore's AKIA cache), so no consumer becomes weaker than today.
- **Implementation gate:** The Bluebottle freshness predicate and its stale-view fixtures (Section 13 step 1) encode these values before any consumer relies on them.
- **Links:** P16, P17, P19, P20, P24 (Stale); inventory P3.
### D4: AWSGW in-process synchronous path
- **Question:** May AWSGW, co-located with the IAM authority, verify and authorize through an in-process synchronous capability instead of consuming the projection?
- **Status:** Open; not decided.
- **Context:** ADR-0005 S1 allows a synchronous verification capability only for a consumer profile that explicitly accepts its availability dependency; this design does not make that choice for AWSGW.
- **Implementation gate:** D4 must be explicitly decided before Phase 1 (Section 13 step 1) begins.
- **Links:** Section 11 (AWSGW rows), Section 13 step 3a; inventory S11-S15, 4.1.
### D5: Failure class when projection state is unavailable or stale
- **Question:** What does a consumer return when it has no completed snapshot, its transport is down, or its view is older than S?
- **Status:** Accepted.
- **Decision:** Fail closed with a retryable HTTP 503 `ServiceUnavailable`.
- **Rationale:** No request may proceed on missing or stale identity state, and a retryable 503 tells clients the failure is transient rather than a credential verdict; AWSGW already answers 503 when its NATS connection is down (`TestCurrentBehaviour_Auth_BackingStateUnavailable`).
- **Implementation gate:** Each consumer's characterisation tests pin its current unavailable-state outcome before it migrates, so the change to 503 is deliberate and visible.
- **Links:** P19, P24 (Unavailable, Stale), P26; inventory 4.1, 4.3.
### D6: Goanna
- **Question:** Is Goanna in scope for the Phase 0 characterisation and the first projection rollout?
- **Status:** Deferred.
- **Decision:** Goanna is not characterised in Phase 0 and is not part of the first rollout.
Any future production Goanna must consume the projection and enforce IAM identity policy; it must not keep its current account-only authentication model.
- **Rationale:** Goanna reads the IAM buckets directly and evaluates no IAM policy today (inventory 2.3, 4.4, from code reading), so adopting the projection without adding policy enforcement would carry an authorization gap forward.
- **Implementation gate:** Before Goanna is used in production: Goanna characterisation tests, adoption of the projection, and IAM identity-policy enforcement (Section 13 step 3d).
- **Links:** P5, P7, P8, P29, Section 11 (Goanna row); inventory G1-G4, 4.4.
### D7: Account-status enforcement
- **Question:** Which consumers enforce account status (ACTIVE, SUSPENDED, TERMINATING)?
- **Status:** Accepted.
- **Decision:** Every production consumer enforces account status from the AccountFact.
- **Rationale:** A suspended account must be refused everywhere; today only AWSGW checks it (inventory S13), Predastore does not (inventory 4.3).
- **Implementation gate:** Each consumer's migration PR includes a test that a SUSPENDED account is refused after the signature.
- **Links:** P5, P23 check 2, Section 11; inventory S13, 4.1 step 9, 4.3.
### D8: Predastore config `[[auth]]` credentials
- **Question:** May Predastore's config-file `[[auth]]` credentials keep overriding IAM?
- **Status:** Rejected.
- **Decision:** Config credentials may not override IAM or bypass bucket ownership or policy evaluation.
Any bootstrap exception needs a separately designed, bounded bootstrap principal.
- **Rationale:** Today `[[auth]]` keys are checked before IAM and returned with `SkipPolicyCheck: true` (inventory P7), a permanent authority bypass that ADR-0005 S1 excludes from the target.
- **Implementation gate:** If any bootstrap exception is kept, the bounded bootstrap principal is designed and reviewed before Predastore migrates (Section 13 step 3c).
- **Links:** Section 11 (Predastore row), Section 13 step 3c; inventory P7, S24, 4.3.
### D9: Owner and publisher of session-validity facts
- **Question:** Which component owns and publishes session CredentialFacts: the IAM authority, or the STS session store that physically holds session records today?
- **Status:** Open; not decided.
- **Context:** ADR-0005 S2 makes IAM the logical authority for session-credential validity and revocation, while STS writes and reads `spinifex-iam-session-credentials` today (inventory S2).
- **Implementation gate:** D9 must be explicitly decided before Phase 1 (Section 13 step 1) begins.
- **Links:** P2, P7, P20, P21, Section 11 (STS row); inventory S2, S12.
### D10: Error ordering for expired sessions
- **Question:** At what point in request verification is `ExpiredToken` reported for an expired session?
- **Status:** Accepted (target).
- **Decision:** Emit `ExpiredToken` only after both session-token validation and SigV4 proof succeed.
- **Rationale:** Today AWSGW reports expiry before checking the session token or the signature, so holding only an ASIA key ID reveals that an expired session existed (`TestCurrentBehaviour_Session_Expired`, `TestCurrentBehaviour_AntiEnumeration_RejectionShapes`).
AWS's actual ordering is unverified, so this is a chosen target, not a parity claim.
- **Implementation gate:** A conformance fixture and an AWSGW test show an expired session with a missing or wrong token, or a bad signature, does not receive `ExpiredToken`; the current-behaviour tests are updated in the same change.
- **Links:** P24 (Expired), P25, Section 12; inventory 4.1 steps 6b-6c, Section 7 finding F2.
### D11: GetSessionToken session after its source key is deleted or deactivated
- **Question:** Does a GetSessionToken session survive deletion or deactivation of the long-term access key that minted it?
- **Status:** Open.
- **Decision so far:** The future contract's session fact model gains an optional `sourceCredentialID`; revocation behaviour does not change now.
- **Context:** Today the session survives both (`TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey`); AWS behaviour is unverified.
- **Implementation gate:** Decided before any consumer enforces `sourceCredentialID`.
- **Links:** P7, P21, Section 12; inventory Section 7 finding F5.
### D12: Session revocation mechanism
- **Question:** Is a session revoked by deleting its session record, as today, or by an AWS-style deny conditioned on token issue time?
- **Status:** Open; not decided.
- **Context:** AWS attaches an inline deny policy conditioned on `aws:TokenIssueTime` to the role [AWS5]; Spinifex deletes session records on principal deletion (P21).
- **Implementation gate:** Decided before session revocation is implemented in the projection producer (Section 13 step 2).
- **Links:** P20, P21, P22, Section 12; inventory S2.