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
55 changes: 55 additions & 0 deletions iam/proofcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
100 changes: 100 additions & 0 deletions iam/proofcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
})
}
Loading