Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions docs/diagrams.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ flowchart TB
hilt["hilt did:web:hilt"] -->|"/s3/request/authorize and<br/>/s3/bucket/info responses"| kp

subgraph stores["proof stores"]
kp["iam.KeyProofs: one DelegationCache<br/>per access key, 24h idle eviction"]
kp["iam.KeyProofs: one DelegationCache<br/>per access key, 24h idle eviction;<br/>also the key's effective S3 action set per bucket"]
ship["uploader.Forge shipProofs:<br/>per-space store, 1h TTL"]
static["AuthServiceProofs:<br/>static container from config"]
tok["tokenstore (tokens.cbor):<br/>empty; dormant login paths only"]
Expand All @@ -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).

Expand Down Expand Up @@ -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,<br/>every command chains to the agent
I->>K: cached derived key verifies SigV4,<br/>cached action set permits the action,<br/>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,<br/>effective action set, fresh delegations
I->>K: cacheProofs (re-delegations)<br/>+ the action set, keyed by the bucket hilt named
opt chain incomplete
I->>H: /s3/bucket/info
I->>K: cache the bucket chain
Expand All @@ -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

Expand Down
105 changes: 75 additions & 30 deletions iam/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -293,61 +311,88 @@ 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)
// and the onward re-delegation. Cross-key mixing is structurally
// 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
}
}

Expand All @@ -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,
Expand Down
Loading
Loading