diff --git a/iam/proofcache.go b/iam/proofcache.go index 95c15552..fd3dd7a9 100644 --- a/iam/proofcache.go +++ b/iam/proofcache.go @@ -31,11 +31,22 @@ import ( // expiring and the janitor sweeping it, the index may still reference it — // lookups re-check liveness against the expiry-aware cache, so the janitor // only bounds memory, never correctness. +// It also holds the effective S3 action set Hilt reported for this access +// key, per bucket (see [DelegationCache.PutPermissions] and +// [DelegationCache.Permits]). The set lives here because a chain probe alone +// cannot enforce a policy: several S3 actions map to the same Forge +// commands — s3:GetObject and s3:ListBucket both need /content/retrieve, and +// s3:PutObject's commands are a superset of s3:AbortMultipartUpload's — so a +// key granted one action would otherwise satisfy the probe for the others. +// Holding the set in the same per-key store means an invalidation drops the +// chains and the set together. type DelegationCache struct { data *gocache.Cache mu sync.RWMutex index map[indexKey]map[string]ucan.Delegation // → CID string → delegation + + perms *gocache.Cache // bucket DID string → map[string]struct{} of S3 actions } // indexKey is the exact-match probe the delegation matcher uses. Powerline @@ -60,6 +71,7 @@ func NewDelegationCache() *DelegationCache { d := &DelegationCache{ data: gocache.New(gocache.NoExpiration, cacheJanitorInterval), index: map[indexKey]map[string]ucan.Delegation{}, + perms: gocache.New(gocache.NoExpiration, cacheJanitorInterval), } d.data.OnEvicted(d.removeFromIndex) return d @@ -169,3 +181,46 @@ func (d *DelegationCache) listDelegations(_ context.Context, aud did.DID, cmd uc } } } + +// PutPermissions caches the effective S3 action set Hilt reported for an +// access key on one bucket, keyed by the bucket's DID and expiring after +// ttl. A nil actions slice means Hilt reported no set at all and nothing is +// cached, so [DelegationCache.Permits] keeps reporting the bucket unknown +// and requests for it keep going to Hilt. An empty but non-nil slice is a +// real answer — the key may do nothing on this bucket — and is cached as +// such, denying every action. +// +// The set lives in the same per-access-key store as the chains, so +// [KeyProofs.InvalidateHolders] drops both together and a stale set can +// never outlive the proofs it was issued with. +func (d *DelegationCache) PutPermissions(bucket did.DID, ttl time.Duration, actions []string) { + if ttl <= 0 || !bucket.Defined() || actions == nil { + return + } + set := make(map[string]struct{}, len(actions)) + for _, a := range actions { + set[a] = struct{}{} + } + d.perms.Set(bucket.String(), set, ttl) +} + +// Permits reports whether the cached action set for bucket contains action. +// known is false when no live set is cached for that bucket — the caller +// must then ask Hilt rather than decide locally. Because the set is keyed by +// the bucket DID Hilt named, a bucket whose local registry space disagrees +// with Hilt's answer simply reports unknown and takes the Hilt path. +func (d *DelegationCache) Permits(bucket did.DID, action string) (allowed, known bool) { + if !bucket.Defined() { + return false, false + } + v, ok := d.perms.Get(bucket.String()) + if !ok { + return false, false + } + set, ok := v.(map[string]struct{}) + if !ok { + return false, false + } + _, allowed = set[action] + return allowed, true +} diff --git a/iam/proofcache_test.go b/iam/proofcache_test.go index dcb3578d..bebbe3fa 100644 --- a/iam/proofcache_test.go +++ b/iam/proofcache_test.go @@ -3,8 +3,10 @@ package iam_test import ( "context" "testing" + "time" contentcmds "github.com/fil-forge/libforge/commands/content" + "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/delegation" @@ -88,3 +90,101 @@ func TestDelegationCacheProofChain(t *testing.T) { require.NoError(t, err) }) } + +// TestDelegationCachePermissions covers the per-bucket action set the local +// fast path consults before it probes for chains. +func TestDelegationCachePermissions(t *testing.T) { + bucketA, err := ed25519.GenerateIssuer() + require.NoError(t, err) + bucketB, err := ed25519.GenerateIssuer() + require.NoError(t, err) + + t.Run("no set cached: unknown", func(t *testing.T) { + c := hiltiam.NewDelegationCache() + allowed, known := c.Permits(bucketA.DID(), "s3:GetObject") + require.False(t, known, "an uncached bucket must send the request to hilt") + require.False(t, allowed) + }) + + t.Run("cached set answers both ways", func(t *testing.T) { + c := hiltiam.NewDelegationCache() + c.PutPermissions(bucketA.DID(), time.Hour, []string{"s3:PutObject"}) + + allowed, known := c.Permits(bucketA.DID(), "s3:PutObject") + require.True(t, known) + require.True(t, allowed) + + allowed, known = c.Permits(bucketA.DID(), "s3:GetObject") + require.True(t, known, "the bucket's set is cached, so the answer is authoritative") + require.False(t, allowed, "an action outside the set is denied") + }) + + t.Run("empty set denies everything", func(t *testing.T) { + c := hiltiam.NewDelegationCache() + c.PutPermissions(bucketA.DID(), time.Hour, []string{}) + + allowed, known := c.Permits(bucketA.DID(), "s3:GetObject") + require.True(t, known, "an empty set is an answer, not a gap") + require.False(t, allowed) + }) + + t.Run("nil set caches nothing", func(t *testing.T) { + // Hilt returning no permissions at all leaves the bucket unknown, so + // the fast path keeps deferring to Hilt instead of denying. + c := hiltiam.NewDelegationCache() + c.PutPermissions(bucketA.DID(), time.Hour, nil) + + _, known := c.Permits(bucketA.DID(), "s3:GetObject") + require.False(t, known) + }) + + t.Run("sets are keyed by bucket", func(t *testing.T) { + c := hiltiam.NewDelegationCache() + c.PutPermissions(bucketA.DID(), time.Hour, []string{"s3:GetObject"}) + + allowed, known := c.Permits(bucketA.DID(), "s3:GetObject") + require.True(t, known) + require.True(t, allowed) + + _, known = c.Permits(bucketB.DID(), "s3:GetObject") + require.False(t, known, "another bucket's set must not answer for this one") + }) + + t.Run("undefined bucket is unknown", func(t *testing.T) { + // A registry row with no space, or a Hilt result with no bucket, + // must not collide with a real bucket's set. + c := hiltiam.NewDelegationCache() + c.PutPermissions(did.Undef, time.Hour, []string{"s3:GetObject"}) + + _, known := c.Permits(did.Undef, "s3:GetObject") + require.False(t, known) + }) + + t.Run("expired set is unknown again", func(t *testing.T) { + c := hiltiam.NewDelegationCache() + c.PutPermissions(bucketA.DID(), 20*time.Millisecond, []string{"s3:GetObject"}) + _, known := c.Permits(bucketA.DID(), "s3:GetObject") + require.True(t, known) + + time.Sleep(50 * time.Millisecond) + _, known = c.Permits(bucketA.DID(), "s3:GetObject") + require.False(t, known, "an expired set must not decide anything") + }) + + t.Run("non-positive ttl caches nothing", func(t *testing.T) { + c := hiltiam.NewDelegationCache() + c.PutPermissions(bucketA.DID(), 0, []string{"s3:GetObject"}) + _, known := c.Permits(bucketA.DID(), "s3:GetObject") + require.False(t, known) + }) + + t.Run("a later set replaces the earlier one", func(t *testing.T) { + c := hiltiam.NewDelegationCache() + c.PutPermissions(bucketA.DID(), time.Hour, []string{"s3:GetObject", "s3:PutObject"}) + c.PutPermissions(bucketA.DID(), time.Hour, []string{"s3:GetObject"}) + + allowed, known := c.Permits(bucketA.DID(), "s3:PutObject") + require.True(t, known) + require.False(t, allowed, "a narrowed set must not keep the dropped action") + }) +}