diff --git a/docs/diagrams.md b/docs/diagrams.md index 47ac2c9c..a00f61a5 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -659,7 +659,7 @@ flowchart TB hilt["hilt did:web:hilt"] -->|"/s3/request/authorize and
/s3/bucket/info responses"| kp subgraph stores["proof stores"] - kp["iam.KeyProofs: one DelegationCache
per access key, 24h idle eviction"] + kp["iam.KeyProofs: one DelegationCache
per access key, 24h idle eviction;
also the key's effective S3 action set per bucket"] ship["uploader.Forge shipProofs:
per-space store, 1h TTL"] static["AuthServiceProofs:
static container from config"] tok["tokenstore (tokens.cbor):
empty; dormant login paths only"] @@ -684,6 +684,13 @@ flowchart TB re-capture it. - Each access key gets its own `DelegationCache`, so a proof chain can never assemble across keys. +- The same store holds the effective S3 action set hilt reported for the key + on each bucket, expiring with the keys and the tenant (next UTC midnight + plus clock skew). One invalidation therefore drops the chains and the set + together. The set is what the fast path enforces: several S3 actions map to + the same Forge commands, and `s3:PutObject`'s commands are a superset of + `s3:GetObject`'s, `s3:ListBucket`'s and `s3:AbortMultipartUpload`'s, so a + chain probe alone would let a put-only key read and abort on the bucket. Cross-references: [`architecture.md` §9](./architecture.md#9-the-system-contract-piri--sprue--indexer). @@ -717,11 +724,15 @@ sequenceDiagram G->>I: GetUserAccountForRequest I->>I: access key ID parsed as a did:key alt local fast path (authorizeLocal) - I->>K: cached derived key verifies SigV4,
every command chains to the agent + I->>K: cached derived key verifies SigV4,
cached action set permits the action,
every command chains to the agent + break action outside the cached set + I-->>G: AccessDenied, hilt not consulted + G-->>C: 403 + end else hilt authorize I->>H: /s3/request/authorize (the signed request) - H-->>I: account, derived SigV4 key, fresh delegations - I->>K: cacheProofs (re-delegations) + H-->>I: account, derived SigV4 key,
effective action set, fresh delegations + I->>K: cacheProofs (re-delegations)
+ the action set, keyed by the bucket hilt named opt chain incomplete I->>H: /s3/bucket/info I->>K: cache the bucket chain @@ -735,14 +746,21 @@ sequenceDiagram - `RoleAdmin` is deliberate: authorization already happened (at hilt or the fast path), so versitygw's role and ACL layers must defer entirely. +- The fast path has three outcomes, not two: authorized, refused, or + undecided. It refuses only on a cached action set that excludes the + action — hilt's own answer for that key and bucket, so re-asking would + repeat it. Everything else undecided (no cached set, an expired one, an + operation that maps to no Forge command) goes to hilt, whose response + refills the caches. - Authorize failures map to S3 errors in `mapAuthError`; unrecognized errors stay 500-class on purpose. - The signing key never leaves hilt as a secret: ingot receives a derived SigV4 key per request (the versitygw fork's `auth.Account.SigningKey`). Sources: `iam/service.go` (GetUserAccountForRequest, authorizeLocal, -cacheProofs, mapAuthError), `server.go` (buildS3API middleware). Review when -`iam/` or the hilt client changes. +cacheProofs, mapAuthError), `iam/proofcache.go` (PutPermissions, Permits), +`server.go` (buildS3API middleware). Review when `iam/` or the hilt client +changes. ## Postgres schema as migrated diff --git a/iam/service.go b/iam/service.go index aa6bfc50..cf36d148 100644 --- a/iam/service.go +++ b/iam/service.go @@ -27,14 +27,17 @@ // // A local fast path (the RFC's "local cache" authorization) avoids the Hilt // round-trip when everything needed is already cached, mirroring Hilt's own -// verification order: the request parses as HMAC SigV4 and a cached derived -// key verifies its signature (hilt/pkg/sigv4.VerifyWithKey) and time bounds; -// the request's S3 action maps to Forge commands (hilt/pkg/s3perm); and the -// key's own store holds a chain to this instance's agent for every such -// command (which, being the key's own store, necessarily carries the key's -// grant too). Anything less falls through to /s3/request/authorize, whose -// response replenishes the caches — so expiry (next UTC midnight, when SigV4 -// scope dates roll over) self-heals. +// verification and authorization order: the request parses as HMAC SigV4 and +// a cached derived key verifies its signature (hilt/pkg/sigv4.VerifyWithKey) +// and time bounds; the request's S3 action maps to Forge commands +// (hilt/pkg/s3perm); the key's cached effective action set for the addressed +// bucket contains the action; and the key's own store holds a chain to this +// instance's agent for every such command (which, being the key's own store, +// necessarily carries the key's grant too). Anything less falls through to +// /s3/request/authorize, whose response replenishes the caches — so expiry +// (next UTC midnight, when SigV4 scope dates roll over) self-heals. An action +// the cached set excludes is refused outright: the set is Hilt's own answer +// for this key and bucket, so re-asking would only repeat it. package iam import ( @@ -170,12 +173,20 @@ func (s *Service) GetUserAccountForRequest(ctx fiber.Ctx, accessKeyStr string) ( store := s.proofs.For(accessKeyID) ctx.Locals(reqscope.ProofStoreKey(), ucanlib.ProofStore(store)) - // Local fast path: with a cached verification key and cached delegation - // chains covering the request's Forge commands, Hilt is not consulted. - // The tenant travels with the request too (the write path encrypts to - // its wrap key); a verified request whose tenant has fallen out of the - // cache takes the Hilt path, whose response refills every cache. - if account, ok := s.authorizeLocal(reqCtx, req, accessKeyStr, store); ok { + // Local fast path: with a cached verification key, a cached action set + // permitting the request's S3 action and cached delegation chains + // covering its Forge commands, Hilt is not consulted. The tenant travels + // with the request too (the write path encrypts to its wrap key); a + // verified request whose tenant has fallen out of the cache takes the + // Hilt path, whose response refills every cache. + if account, ok, err := s.authorizeLocal(reqCtx, req, accessKeyStr, store); err != nil { + // A refusal off the cached action set: that set is Hilt's own answer + // for this key and bucket, so asking again would return the same + // refusal. Returned verbatim (not wrapped) so versitygw's renderer + // type-asserts it, matching how mapAuthError returns Hilt's + // OperationNotPermitted. + return auth.Account{}, err + } else if ok { if tenant, found := s.tenants.Get(accessKeyStr); found { ctx.Locals(reqscope.TenantKey(), tenant) return account, nil @@ -224,6 +235,13 @@ func (s *Service) GetUserAccountForRequest(ctx fiber.Ctx, accessKeyStr string) ( // and stashed on this request for the write path. s.tenants.Put(accessKeyStr, ttl, ok.Tenant) ctx.Locals(reqscope.TenantKey(), ok.Tenant) + // The key's effective action set on this bucket is cached to the same + // horizon, in the key's own proof store so a revocation drops the set + // with the chains. Hilt names the bucket; a result without one + // (ListBuckets, CreateBucket) scopes no set. + if ok.Bucket != nil { + store.PutPermissions(*ok.Bucket, ttl, ok.Permissions.Entries[accessKeyID]) + } // Bucket is nil for bucket-level operations (CreateBucket, ListBuckets), // which authorize without addressing an existing bucket. @@ -293,53 +311,80 @@ func mapAuthError(err error) (error, bool) { } } -// authorizeLocal is the fast path, mirroring Hilt's own verification -// order over THIS key's proof store. It reports ok=false whenever anything -// needed isn't cached or doesn't check out; the caller then takes the Hilt -// path, whose response replenishes the caches. -func (s *Service) authorizeLocal(ctx context.Context, req s3.Request, access string, store *DelegationCache) (auth.Account, bool) { +// authorizeLocal is the fast path, mirroring Hilt's own verification and +// authorization order over THIS key's proof store: signature, then the +// effective action set, then the proof chains. It reports ok=true when the +// request checks out end to end. A non-nil error is a refusal: the key's +// cached action set for this bucket excludes the request's action, and that +// set is Hilt's own answer, so the caller refuses rather than asking again. +// Both zero means the fast path could not decide — something it needs isn't +// cached or doesn't check out — and the caller takes the Hilt path, whose +// response replenishes the caches. +// +// The action set is what makes the chain probe safe to trust. Several S3 +// actions share Forge commands and s3:PutObject's command set is a superset +// of s3:GetObject's, s3:ListBucket's and s3:AbortMultipartUpload's, so a +// probe alone would let a put-only key read and abort on the same bucket. +func (s *Service) authorizeLocal(ctx context.Context, req s3.Request, access string, store *DelegationCache) (auth.Account, bool, error) { if s.buckets == nil || !s.agent.Defined() { - return auth.Account{}, false + return auth.Account{}, false, nil } // 1. Parse the signature envelope. Only HMAC SigV4 is fast-pathed: the // gateway re-verifies with the returned key and only speaks HMAC. sr, err := sigv4.Parse(sigv4.Request{Method: req.Method, Headers: req.Headers, URL: req.URL}) if err != nil || sr.Scheme != sigv4.SchemeV4 { - return auth.Account{}, false + return auth.Account{}, false, nil } // 2. Cached key verifies the signature and the request is in time // bounds. A scope-rolled or rotated key simply fails here. key, ok := s.keys.Get(access, s3.KeyKindSigV4) if !ok { - return auth.Account{}, false + return auth.Account{}, false, nil } if sigv4.VerifyWithKey(sr, key) != nil { - return auth.Account{}, false + return auth.Account{}, false, nil } if sigv4.ValidateTimeBounds(sr, time.Now()) != nil { - return auth.Account{}, false + return auth.Account{}, false, nil } // 3. The S3 action must map to Forge commands. Bucket-level operations // (create/delete/list-buckets) map to none — they go to Hilt regardless. op, err := hiltauth.OperationFor(req) if err != nil { - return auth.Account{}, false + return auth.Account{}, false, nil } cmds := s3perm.CommandsFor(op.Permission()) if len(cmds) == 0 { - return auth.Account{}, false + return auth.Account{}, false, nil } // 4. The delegation subject is the bucket's space. st, err := s.buckets.Get(ctx, bucketFromURL(req.URL)) if err != nil || !st.Space.Defined() { - return auth.Account{}, false + return auth.Account{}, false, nil + } + + // 5. The key's cached effective action set for this bucket decides + // whether the action is permitted at all. No set cached means Hilt has + // not answered for this bucket (or the answer expired, or the local + // registry's space disagrees with the bucket Hilt named) — ask Hilt. A + // cached set that excludes the action is a refusal. + if allowed, known := store.Permits(st.Space, op.Permission()); !known { + return auth.Account{}, false, nil + } else if !allowed { + s.logger.Debug("hilt/iam: request denied by cached action set", + zap.String("access", access), + zap.String("operation", op.String()), + zap.String("action", op.Permission()), + zap.Stringer("bucket", st.Space), + ) + return auth.Account{}, false, s3err.GetAPIError(s3err.ErrAccessDenied) } - // 5. Every command must be covered by a chain to this instance's agent + // 6. Every command must be covered by a chain to this instance's agent // in THIS key's store. Because the store holds only keyDID's delegations, // a resolving agent chain is necessarily space→…→keyDID→agent — it // carries both keyDID's own grant (Hilt's permission + bucket scoping) @@ -347,7 +392,7 @@ func (s *Service) authorizeLocal(ctx context.Context, req s3.Request, access str // impossible, so one probe suffices. for _, cmd := range cmds { if chain, _, err := store.ProofChain(ctx, s.agent, cmd, st.Space); err != nil || len(chain) == 0 { - return auth.Account{}, false + return auth.Account{}, false, nil } } @@ -364,7 +409,7 @@ func (s *Service) authorizeLocal(ctx context.Context, req s3.Request, access str // ownership for hilt-managed keys (buckets default to root-owned) and // its admin APIs are not mounted. Role: auth.RoleAdmin, - }, true + }, true, nil } // untilNextUTCMidnight is the cache TTL horizon for verification keys, diff --git a/iam/service_internal_test.go b/iam/service_internal_test.go index 6d2b4b90..369ca334 100644 --- a/iam/service_internal_test.go +++ b/iam/service_internal_test.go @@ -17,6 +17,7 @@ import ( hiltclient "github.com/fil-forge/hilt/pkg/client" hiltauth "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/s3perm" "github.com/fil-forge/hilt/pkg/sigv4" "github.com/fil-forge/ingot/internal/reqscope" "github.com/fil-forge/ingot/registry" @@ -37,7 +38,15 @@ import ( // sigv4.VerifyWithKey accepts it. func signedGet(t *testing.T, host, rawPath, accessKeyID, secret string, when time.Time) s3.Request { t.Helper() - req, err := http.NewRequest(http.MethodGet, "http://"+host+rawPath, nil) + return signedRequest(t, http.MethodGet, host, rawPath, accessKeyID, secret, when) +} + +// signedRequest is signedGet for any method, so the operation classifier sees +// the shapes it distinguishes (a DELETE with ?uploadId is an abort, not an +// object delete). +func signedRequest(t *testing.T, method, host, rawPath, accessKeyID, secret string, when time.Time) s3.Request { + t.Helper() + req, err := http.NewRequest(method, "http://"+host+rawPath, nil) require.NoError(t, err) req.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD") @@ -52,7 +61,7 @@ func signedGet(t *testing.T, host, rawPath, accessKeyID, secret string, when tim for k := range req.Header { headers[k] = req.Header.Get(k) } - return s3.Request{Method: http.MethodGet, Headers: headers, URL: req.URL.RequestURI()} + return s3.Request{Method: method, Headers: headers, URL: req.URL.RequestURI()} } // fixedResolver returns one bucket→space mapping (or not-found for others). @@ -71,17 +80,30 @@ func (r fixedResolver) Get(_ context.Context, name string) (*registry.State, err // retrieveChain mints the RFC chain space→tenant→accessKey→agent for // /content/retrieve, all subject = space; returns the delegations. func retrieveChain(t *testing.T, spaceIssuer, accessKey, agent ucan.Issuer) []ucan.Delegation { + t.Helper() + return chainFor(t, spaceIssuer, accessKey, agent, contentcmds.Retrieve.Command) +} + +// chainFor mints the RFC chain space→tenant→accessKey→agent for each command, +// all subject = space, through one tenant. It stands in for the delegations +// Hilt issues when it grants an access key a permission: the whole command set +// that permission maps to (see hilt/pkg/s3perm). +func chainFor(t *testing.T, spaceIssuer, accessKey, agent ucan.Issuer, cmds ...ucan.Command) []ucan.Delegation { t.Helper() space := spaceIssuer.DID() tenant, err := ed25519.GenerateIssuer() require.NoError(t, err) - root, err := contentcmds.Retrieve.Delegate(spaceIssuer, tenant.DID(), space, delegation.WithNoExpiration()) - require.NoError(t, err) - mid, err := contentcmds.Retrieve.Delegate(tenant, accessKey.DID(), space, delegation.WithNoExpiration()) - require.NoError(t, err) - leaf, err := contentcmds.Retrieve.Delegate(accessKey, agent.DID(), space, delegation.WithNoExpiration()) - require.NoError(t, err) - return []ucan.Delegation{root, mid, leaf} + var dlgs []ucan.Delegation + for _, cmd := range cmds { + root, err := delegation.Delegate(spaceIssuer, tenant.DID(), space, cmd, delegation.WithNoExpiration()) + require.NoError(t, err) + mid, err := delegation.Delegate(tenant, accessKey.DID(), space, cmd, delegation.WithNoExpiration()) + require.NoError(t, err) + leaf, err := delegation.Delegate(accessKey, agent.DID(), space, cmd, delegation.WithNoExpiration()) + require.NoError(t, err) + dlgs = append(dlgs, root, mid, leaf) + } + return dlgs } // localService builds a Service with the fast path enabled and the given @@ -115,17 +137,29 @@ func TestAuthorizeLocal(t *testing.T) { // authLocal mirrors GetUserAccountForRequest: resolve the requesting // key's own store and hand it to the fast path. - authLocal := func(s *Service) (auth.Account, bool) { + authLocal := func(s *Service) (auth.Account, bool, error) { store := s.proofs.For(accessKey.DID()) return s.authorizeLocal(context.Background(), req, accessKeyID, store) } + // permit caches the effective action set Hilt would have returned for + // this key on the bucket. Without it the fast path has no answer and + // defers to Hilt, so every case that expects a decision seeds one. + permit := func(s *Service, actions ...string) { + if actions == nil { + actions = []string{} // an answer of "nothing", not the absence of one + } + s.proofs.For(accessKey.DID()).PutPermissions(space, time.Hour, actions) + } + t.Run("all cached: authorized locally", func(t *testing.T) { s := localService(agent.DID(), resolver) s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) + permit(s, "s3:GetObject") - acct, ok := authLocal(s) + acct, ok, err := authLocal(s) + require.NoError(t, err) require.True(t, ok, "should authorize without Hilt") require.Equal(t, accessKeyID, acct.Access) require.Equal(t, key, acct.SigningKey) @@ -134,7 +168,9 @@ func TestAuthorizeLocal(t *testing.T) { t.Run("no cached key: fall through", func(t *testing.T) { s := localService(agent.DID(), resolver) s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) - _, ok := authLocal(s) + permit(s, "s3:GetObject") + _, ok, err := authLocal(s) + require.NoError(t, err) require.False(t, ok) }) @@ -142,15 +178,19 @@ func TestAuthorizeLocal(t *testing.T) { s := localService(agent.DID(), resolver) s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: []byte("wrong-key-32-bytes-......xxxxxxx")}) s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) - _, ok := authLocal(s) + permit(s, "s3:GetObject") + _, ok, err := authLocal(s) + require.NoError(t, err) require.False(t, ok) }) t.Run("missing chain: fall through", func(t *testing.T) { s := localService(agent.DID(), resolver) s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) + permit(s, "s3:GetObject") // No delegations cached for this key. - _, ok := authLocal(s) + _, ok, err := authLocal(s) + require.NoError(t, err) require.False(t, ok) }) @@ -158,8 +198,46 @@ func TestAuthorizeLocal(t *testing.T) { s := localService(agent.DID(), fixedResolver{name: "other"}) s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) - _, ok := authLocal(s) + permit(s, "s3:GetObject") + _, ok, err := authLocal(s) + require.NoError(t, err) + require.False(t, ok) + }) + + t.Run("no cached action set: fall through", func(t *testing.T) { + // Everything else checks out; without Hilt's answer on what this + // key may do here, the request goes to Hilt rather than through. + s := localService(agent.DID(), resolver) + s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) + s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) + + _, ok, err := authLocal(s) + require.NoError(t, err) + require.False(t, ok) + }) + + t.Run("empty action set: denied", func(t *testing.T) { + // A principal whose policy grants nothing on this bucket. The empty + // set is an answer, so the request is refused, not re-asked. + s := localService(agent.DID(), resolver) + s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) + s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) + permit(s) + + _, ok, err := authLocal(s) + require.False(t, ok) + require.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err) + }) + + t.Run("action outside the set: denied", func(t *testing.T) { + s := localService(agent.DID(), resolver) + s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) + s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) + permit(s, "s3:DeleteObject") + + _, ok, err := authLocal(s) require.False(t, ok) + require.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err) }) // Isolation: a full chain deposited under a DIFFERENT access key (even @@ -172,12 +250,94 @@ func TestAuthorizeLocal(t *testing.T) { s := localService(agent.DID(), resolver) s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) s.proofs.Deposit(otherKey.DID(), retrieveChain(t, spaceIssuer, otherKey, agent)...) + permit(s, "s3:GetObject") - _, ok := authLocal(s) + _, ok, err := authLocal(s) + require.NoError(t, err) require.False(t, ok, "must not see another key's store") }) } +// TestPutOnlyKeyIsDeniedOnReads pins the escalation the action set closes. +// s3perm maps s3:PutObject to a command set that contains every command +// s3:GetObject, s3:ListBucket and s3:AbortMultipartUpload need, so a key +// granted only s3:PutObject satisfies the fast path's chain probe for all +// three. The cached action set is what refuses them. +func TestPutOnlyKeyIsDeniedOnReads(t *testing.T) { + accessKey, err := ed25519.GenerateIssuer() + require.NoError(t, err) + spaceIssuer, err := ed25519.GenerateIssuer() + require.NoError(t, err) + agent, err := ed25519.GenerateIssuer() + require.NoError(t, err) + + accessKeyID := accessKey.DID().Identifier() + const secret = "test-secret-access-key" + space := spaceIssuer.DID() + resolver := fixedResolver{name: "bkt", state: ®istry.State{Name: "bkt", Space: space}} + + // The delegations Hilt issues for a key granted only s3:PutObject. + putCommands := s3perm.CommandsFor("s3:PutObject") + require.NotEmpty(t, putCommands) + putChains := chainFor(t, spaceIssuer, accessKey, agent, putCommands...) + + // authorize builds a service holding those chains, the derived key for + // the request, and the put-only action set, then runs the fast path. + authorize := func(t *testing.T, req s3.Request) (bool, error) { + t.Helper() + sr, err := sigv4.Parse(sigv4.Request{Method: req.Method, Headers: req.Headers, URL: req.URL}) + require.NoError(t, err) + key, err := sigv4.DeriveKey(sr, secret) + require.NoError(t, err) + + s := localService(agent.DID(), resolver) + s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) + s.proofs.Deposit(accessKey.DID(), putChains...) + s.proofs.For(accessKey.DID()).PutPermissions(space, time.Hour, []string{"s3:PutObject"}) + + _, ok, err := s.authorizeLocal(context.Background(), req, accessKeyID, s.proofs.For(accessKey.DID())) + return ok, err + } + + now := time.Now() + denied := map[string]s3.Request{ + "GetObject": signedRequest(t, http.MethodGet, "s3.example", "/bkt/obj", accessKeyID, secret, now), + "ListBucket": signedRequest(t, http.MethodGet, "s3.example", "/bkt", accessKeyID, secret, now), + "AbortMultipartUpload": signedRequest(t, http.MethodDelete, "s3.example", "/bkt/obj?uploadId=abc", accessKeyID, secret, now), + } + for name, req := range denied { + t.Run(name+" is denied", func(t *testing.T) { + // The chains alone would let it through: the operation's + // commands are a subset of s3:PutObject's. + op, err := hiltauth.OperationFor(req) + require.NoError(t, err) + require.Equal(t, name, op.String()) + require.Subset(t, commandStrings(putCommands), commandStrings(s3perm.CommandsFor(op.Permission())), + "the defect only exists while the commands are a subset") + + ok, err := authorize(t, req) + require.False(t, ok) + require.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err) + }) + } + + t.Run("PutObject is still authorized", func(t *testing.T) { + req := signedRequest(t, http.MethodPut, "s3.example", "/bkt/obj", accessKeyID, secret, now) + ok, err := authorize(t, req) + require.NoError(t, err) + require.True(t, ok) + }) +} + +// commandStrings renders commands for set comparison in assertions. +func commandStrings(cmds []ucan.Command) []string { + out := make([]string, 0, len(cmds)) + for _, c := range cmds { + out = append(out, c.String()) + } + return out +} + // hiltErr reproduces the wrapping depth a real authorize failure arrives with: // binding.Unpack wraps the decoded ErrorModel, the Hilt client wraps that, and // GetUserAccountForRequest wraps once more — mapAuthError must see through all @@ -312,11 +472,18 @@ func TestFastPathTenant(t *testing.T) { return acct, stashed, authErr } + // permit caches the effective action set for this key on the bucket, as + // an authorize response would. + permit := func(s *Service, actions ...string) { + s.proofs.For(accessKey.DID()).PutPermissions(spaceIssuer.DID(), time.Hour, actions) + } + t.Run("cached tenant is stashed without consulting Hilt", func(t *testing.T) { s := localService(agent.DID(), resolver) s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) s.tenants.Put(accessKeyID, time.Hour, tenant) s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) + permit(s, "s3:GetObject") acct, stashed, err := drive(t, s) require.NoError(t, err) @@ -329,9 +496,27 @@ func TestFastPathTenant(t *testing.T) { s := localService(agent.DID(), resolver) s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) + permit(s, "s3:GetObject") _, _, err := drive(t, s) require.ErrorContains(t, err, "hilt consulted") require.Equal(t, 1, s.authorizer.(*refusingAuthorizer).calls) }) + + t.Run("action outside the cached set is refused without consulting Hilt", func(t *testing.T) { + s := localService(agent.DID(), resolver) + s.keys.Put(accessKeyID, time.Hour, s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: key}) + s.tenants.Put(accessKeyID, time.Hour, tenant) + s.proofs.Deposit(accessKey.DID(), retrieveChain(t, spaceIssuer, accessKey, agent)...) + permit(s, "s3:PutObject") + + _, stashed, err := drive(t, s) + // s3err.APIError arrives verbatim so versitygw renders AccessDenied. + var s3e s3err.S3Error + require.ErrorAs(t, err, &s3e) + require.Equal(t, "AccessDenied", s3e.BaseError().Code) + require.Equal(t, 403, s3e.StatusCode()) + require.Zero(t, s.authorizer.(*refusingAuthorizer).calls, "a cached refusal must not re-ask Hilt") + require.Nil(t, stashed, "a refused request must not carry a tenant onward") + }) } diff --git a/iam/service_test.go b/iam/service_test.go index 5c8ac22d..360a6ca8 100644 --- a/iam/service_test.go +++ b/iam/service_test.go @@ -249,6 +249,68 @@ func TestProofChainCapture(t *testing.T) { }) } +// TestPermissionCapture covers the other half of enforcement: the effective +// action set an authorize response carries is cached in the key's own proof +// store, keyed by the bucket Hilt named, so the fast path can decide the +// key's next request without another round trip. +func TestPermissionCapture(t *testing.T) { + access, keyDID := newAccessKey(t) + sigv4 := s3.VerificationKey{Kind: s3.KeyKindSigV4, Data: []byte("dk")} + + t.Run("the effective set is cached under the bucket", func(t *testing.T) { + res := authorizeOK(t, keyDID, sigv4) + res.Permissions = s3.PermissionSet{Entries: map[did.DID][]string{keyDID: {"s3:PutObject"}}} + proofs := iam.NewKeyProofs() + svc := iam.New(&fakeAuthorizer{res: res}, proofs, iam.NewVerificationKeyCache(), iam.NewTenantCache()) + + _, err := resolveForRequest(t, svc, access, + httptest.NewRequest(http.MethodPut, "http://example.com/bkt/key", nil)) + require.NoError(t, err) + + store := proofs.For(keyDID) + allowed, known := store.Permits(*res.Bucket, "s3:PutObject") + require.True(t, known, "the authorize answer must be cached") + require.True(t, allowed) + + allowed, known = store.Permits(*res.Bucket, "s3:GetObject") + require.True(t, known) + require.False(t, allowed, "an action the key does not hold must be denied from cache") + }) + + t.Run("a result with no bucket caches no set", func(t *testing.T) { + // ListBuckets and CreateBucket address no existing bucket, so there + // is nothing to scope a set to. + res := authorizeOK(t, keyDID, sigv4) + bucket := *res.Bucket + res.Bucket = nil + proofs := iam.NewKeyProofs() + svc := iam.New(&fakeAuthorizer{res: res}, proofs, iam.NewVerificationKeyCache(), iam.NewTenantCache()) + + _, err := resolveForRequest(t, svc, access, + httptest.NewRequest(http.MethodGet, "http://example.com/", nil)) + require.NoError(t, err) + + _, known := proofs.For(keyDID).Permits(bucket, "s3:GetObject") + require.False(t, known) + }) + + t.Run("a result with no permissions for the key caches no set", func(t *testing.T) { + // A Hilt that reports nothing leaves the bucket unknown, so requests + // keep going to Hilt instead of being refused locally. + res := authorizeOK(t, keyDID, sigv4) + res.Permissions = s3.PermissionSet{} + proofs := iam.NewKeyProofs() + svc := iam.New(&fakeAuthorizer{res: res}, proofs, iam.NewVerificationKeyCache(), iam.NewTenantCache()) + + _, err := resolveForRequest(t, svc, access, + httptest.NewRequest(http.MethodGet, "http://example.com/bkt/key", nil)) + require.NoError(t, err) + + _, known := proofs.For(keyDID).Permits(*res.Bucket, "s3:GetObject") + require.False(t, known) + }) +} + // TestBaseIAMServiceParity pins the non-request IAMService surface to // IAMServiceSingle's behavior: account management belongs to Hilt. func TestBaseIAMServiceParity(t *testing.T) {