diff --git a/iam/keyproofs.go b/iam/keyproofs.go index 98d8ccfe..1cb75125 100644 --- a/iam/keyproofs.go +++ b/iam/keyproofs.go @@ -23,9 +23,35 @@ import ( // TTL is refreshed on every access, so an active key is never evicted // mid-use; a caller already holding a *DelegationCache is unaffected by // eviction regardless. +// Keys are also indexed by the (tenant, principal) pair Hilt reports for +// them, so a principal invalidation from the firehose can drop every store +// belonging to that principal without knowing which keys they are. See +// [KeyProofs.Bind] and [KeyProofs.InvalidatePrincipal]. type KeyProofs struct { mu sync.Mutex byKey *gocache.Cache // access-key DID string → *DelegationCache + + // The principal index has its own mutex, deliberately not mu: go-cache + // calls the byKey eviction hook synchronously from Delete, and + // InvalidateHolders deletes while holding mu, so a hook that took mu + // would deadlock. + pmu sync.Mutex + byPrincipal map[PrincipalRef]map[string]struct{} // → set of access-key DID strings + keyPrincipal map[string]PrincipalRef // access-key DID string → its pair +} + +// PrincipalRef identifies a principal: a userId is unique only within its +// tenant, so the tenant is part of the identity. It is what Hilt names in an +// authorize result and what a firehose principal event carries. +type PrincipalRef struct { + Tenant did.DID + Principal string +} + +// Defined reports whether the reference names a principal. An undefined +// tenant or an empty userId is not a principal and never indexes anything. +func (r PrincipalRef) Defined() bool { + return r.Tenant.Defined() && r.Principal != "" } // keyProofsIdleTTL is how long a key's cache survives with no access before @@ -36,7 +62,110 @@ const keyProofsIdleTTL = 24 * time.Hour // NewKeyProofs returns an empty per-key proof store registry. func NewKeyProofs() *KeyProofs { - return &KeyProofs{byKey: gocache.New(keyProofsIdleTTL, keyProofsIdleTTL)} + k := &KeyProofs{ + byKey: gocache.New(keyProofsIdleTTL, keyProofsIdleTTL), + byPrincipal: map[PrincipalRef]map[string]struct{}{}, + keyPrincipal: map[string]PrincipalRef{}, + } + k.byKey.OnEvicted(k.unbind) + return k +} + +// Bind records that key belongs to a principal, so +// [KeyProofs.InvalidatePrincipal] can find it. Idempotent: re-binding the +// same pair changes nothing. A key that moves to another principal (Hilt +// reporting a different pair) is re-indexed under the new one. +func (k *KeyProofs) Bind(key did.DID, ref PrincipalRef) { + if !ref.Defined() || !key.Defined() { + return + } + id := key.String() + k.pmu.Lock() + defer k.pmu.Unlock() + if cur, ok := k.keyPrincipal[id]; ok { + if cur == ref { + return + } + k.detachLocked(id, cur) + } + k.keyPrincipal[id] = ref + ids, ok := k.byPrincipal[ref] + if !ok { + ids = map[string]struct{}{} + k.byPrincipal[ref] = ids + } + ids[id] = struct{}{} +} + +// InvalidatePrincipal drops the proof store of every access key bound to +// ref, returning the affected access-key DIDs. It mirrors +// [KeyProofs.InvalidateHolders]: the whole store goes, so the key's next +// request falls through to Hilt, which re-authorizes it against the +// principal's current access (or refuses). Idempotent — the bindings go +// with the stores, so a re-delivered event affects nothing — and a pair +// nothing is bound to is a no-op. +func (k *KeyProofs) InvalidatePrincipal(ref PrincipalRef) []did.DID { + if !ref.Defined() { + return nil + } + // Snapshot the bound keys and release the index lock before deleting: + // the delete runs the eviction hook, which takes this same lock. + k.pmu.Lock() + ids := make([]string, 0, len(k.byPrincipal[ref])) + for id := range k.byPrincipal[ref] { + ids = append(ids, id) + } + k.pmu.Unlock() + + var affected []did.DID + for _, id := range ids { + key, err := did.Parse(id) + if err != nil { + continue // unreachable: ids are Bind(did, …).String() + } + k.mu.Lock() + k.byKey.Delete(id) // prunes the binding via unbind, when a store exists + k.mu.Unlock() + // A key whose store had already been evicted leaves no store to + // delete and so no hook to run; drop its binding here so the pair + // stays idempotent. The liveness guard in unbind keeps a store that + // a concurrent request re-created (and re-bound) after the delete. + k.unbind(id, nil) + affected = append(affected, key) + } + return affected +} + +// unbind is the byKey eviction hook: a dropped store takes its principal +// binding with it. Guarded against the re-add race the same way +// [DelegationCache.removeFromIndex] is — an idle eviction that raced a +// fresh For(key) must not unbind the live store. The liveness check runs +// under pmu so a Bind that lands between the eviction and this hook cannot +// be undone: Bind takes pmu too, so either it ran before the check (and the +// store reads live) or it runs after the detach (and re-indexes the key). +func (k *KeyProofs) unbind(id string, _ any) { + k.pmu.Lock() + defer k.pmu.Unlock() + if _, live := k.byKey.Get(id); live { + return + } + if ref, ok := k.keyPrincipal[id]; ok { + k.detachLocked(id, ref) + } +} + +// detachLocked removes id from ref's key set and forgets its binding. The +// caller holds pmu. +func (k *KeyProofs) detachLocked(id string, ref PrincipalRef) { + delete(k.keyPrincipal, id) + ids, ok := k.byPrincipal[ref] + if !ok { + return + } + delete(ids, id) + if len(ids) == 0 { + delete(k.byPrincipal, ref) + } } // For returns key's proof store, creating it on first use. The idle TTL is diff --git a/iam/keyproofs_internal_test.go b/iam/keyproofs_internal_test.go new file mode 100644 index 00000000..cc7aa957 --- /dev/null +++ b/iam/keyproofs_internal_test.go @@ -0,0 +1,40 @@ +package iam + +import ( + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/stretchr/testify/require" +) + +// An eviction hook that fires after a request has already re-created and +// re-bound the key's store must leave the new binding alone; otherwise the +// live store would be missed by the next principal invalidation. +func TestUnbindKeepsLiveRebinding(t *testing.T) { + kp := NewKeyProofs() + key, err := ed25519.GenerateIssuer() + require.NoError(t, err) + ref := PrincipalRef{Tenant: key.DID(), Principal: "member"} + id := key.DID().String() + + kp.For(key.DID()) + kp.Bind(key.DID(), ref) + + // The stale hook for an evicted store runs after the re-create and + // re-bind: the store is live, so the binding stays. + kp.unbind(id, nil) + require.Equal(t, []string{id}, didStrings(kp.InvalidatePrincipal(ref))) + + // With no live store the hook drops the binding, and the pair is empty. + kp.unbind(id, nil) + require.Empty(t, kp.InvalidatePrincipal(ref)) +} + +func didStrings(ids []did.DID) []string { + out := make([]string, 0, len(ids)) + for _, d := range ids { + out = append(out, d.String()) + } + return out +} diff --git a/iam/keyproofs_test.go b/iam/keyproofs_test.go index 9778baab..944f4910 100644 --- a/iam/keyproofs_test.go +++ b/iam/keyproofs_test.go @@ -5,6 +5,7 @@ import ( "testing" 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" @@ -64,3 +65,96 @@ func TestKeyProofsForIsStable(t *testing.T) { require.NoError(t, err) require.Len(t, chain, 1) } + +// TestKeyProofsPrincipalIndex covers the (tenant, principal) index a +// firehose principal event resolves against. +func TestKeyProofsPrincipalIndex(t *testing.T) { + tenant := did.MustParse("did:plc:ewvi7nxzyoun6zhxrhs64oiz") + ada := iam.PrincipalRef{Tenant: tenant, Principal: "ada"} + grace := iam.PrincipalRef{Tenant: tenant, Principal: "grace"} + + t.Run("both stores for a pair drop, a sibling principal survives", func(t *testing.T) { + kp := iam.NewKeyProofs() + keyA, dlgA := boundKey(t, kp, ada) + keyB, dlgB := boundKey(t, kp, ada) + keyC, dlgC := boundKey(t, kp, grace) + + affected := kp.InvalidatePrincipal(ada) + require.ElementsMatch(t, []did.DID{keyA, keyB}, affected) + require.False(t, kp.For(keyA).Contains(dlgA.Link()), "ada's first key must lose its store") + require.False(t, kp.For(keyB).Contains(dlgB.Link()), "ada's second key must lose its store") + require.True(t, kp.For(keyC).Contains(dlgC.Link()), "grace's key must be untouched") + }) + + t.Run("re-delivery is a no-op", func(t *testing.T) { + kp := iam.NewKeyProofs() + key, _ := boundKey(t, kp, ada) + + require.Equal(t, []did.DID{key}, kp.InvalidatePrincipal(ada)) + require.Empty(t, kp.InvalidatePrincipal(ada), "the binding goes with the store") + }) + + t.Run("an unknown pair is a no-op", func(t *testing.T) { + kp := iam.NewKeyProofs() + key, dlg := boundKey(t, kp, ada) + + require.Empty(t, kp.InvalidatePrincipal(grace)) + require.Empty(t, kp.InvalidatePrincipal(iam.PrincipalRef{ + Tenant: did.MustParse("did:plc:44444444444444444444444444"), + Principal: "ada", + }), "the same userId under another tenant is another principal") + require.True(t, kp.For(key).Contains(dlg.Link())) + }) + + t.Run("binding is idempotent", func(t *testing.T) { + kp := iam.NewKeyProofs() + key, _ := boundKey(t, kp, ada) + kp.Bind(key, ada) + + require.Equal(t, []did.DID{key}, kp.InvalidatePrincipal(ada)) + }) + + t.Run("a key re-bound to another principal follows it", func(t *testing.T) { + kp := iam.NewKeyProofs() + key, _ := boundKey(t, kp, ada) + kp.Bind(key, grace) + + require.Empty(t, kp.InvalidatePrincipal(ada), "the old pair must not still claim the key") + require.Equal(t, []did.DID{key}, kp.InvalidatePrincipal(grace)) + }) + + t.Run("an incomplete reference indexes nothing", func(t *testing.T) { + kp := iam.NewKeyProofs() + key, dlg := boundKey(t, kp, iam.PrincipalRef{Tenant: tenant}) + kp.Bind(key, iam.PrincipalRef{Principal: "ada"}) + + require.Empty(t, kp.InvalidatePrincipal(iam.PrincipalRef{Tenant: tenant})) + require.Empty(t, kp.InvalidatePrincipal(iam.PrincipalRef{Principal: "ada"})) + require.True(t, kp.For(key).Contains(dlg.Link())) + }) + + // The index must not deadlock against InvalidateHolders: go-cache runs + // the byKey eviction hook synchronously while that method holds the + // registry mutex, so the hook takes a different lock. + t.Run("a CID revocation also drops the binding", func(t *testing.T) { + kp := iam.NewKeyProofs() + key, dlg := boundKey(t, kp, ada) + + require.Equal(t, []did.DID{key}, kp.InvalidateHolders(dlg.Link())) + require.Empty(t, kp.InvalidatePrincipal(ada), "the evicted store's binding must be gone") + }) +} + +// boundKey generates an access key, binds it to ref and deposits one +// delegation in its store, returning the key and that delegation. Whether +// the store survives an invalidation is read back through the delegation. +func boundKey(t *testing.T, kp *iam.KeyProofs, ref iam.PrincipalRef) (did.DID, ucan.Delegation) { + t.Helper() + signer, err := ed25519.GenerateIssuer() + require.NoError(t, err) + kp.Bind(signer.DID(), ref) + dlg, _ := grant(t) + kp.Deposit(signer.DID(), dlg) + require.True(t, kp.For(signer.DID()).Contains(dlg.Link())) + return signer.DID(), dlg +} diff --git a/iam/revoker.go b/iam/revoker.go index 89c2e65d..61d6d08f 100644 --- a/iam/revoker.go +++ b/iam/revoker.go @@ -8,14 +8,15 @@ import ( "go.uber.org/zap" ) -// Revoker applies UCAN revocations to the local authorization caches: it is -// the bridge between the revocation-firehose consumer (which sees only -// delegation CIDs) and the per-access-key state the local fast path reads -// (see Service.authorizeLocal). After Revoke returns for a delegation in key -// K's chains, a request signed with K cannot authorize locally — its proof -// store is gone (defeating the chain probe) and so are its verification keys -// (defeating local signature verification) — so the request falls through to -// Hilt, which refuses for a deleted key. +// Revoker applies invalidations to the local authorization caches: it is the +// bridge between the firehose consumer (which sees delegation CIDs and +// principal references) and the per-access-key state the local fast path +// reads (see Service.authorizeLocal). After Revoke or InvalidatePrincipal +// returns for key K, a request signed with K cannot authorize locally — its +// proof store is gone (defeating the chain probe and the cached action set) +// and so are its verification keys (defeating local signature verification) +// — so the request falls through to Hilt, which re-authorizes it against the +// current access or refuses. type Revoker struct { proofs *KeyProofs keys *VerificationKeyCache @@ -39,15 +40,37 @@ func NewRevoker(proofs *KeyProofs, keys *VerificationKeyCache, tenants *TenantCa // revocation is a no-op. func (r *Revoker) Revoke(revoked cid.Cid) []did.DID { affected := r.proofs.InvalidateHolders(revoked) + r.clearKeys(affected, "iam: access key caches cleared by revocation", + zap.Stringer("revoked", revoked)) + return affected +} + +// InvalidatePrincipal clears the caches of every access key bound to +// (tenant, principal), returning the affected key DIDs. Hilt publishes the +// invalidation before it commits a change to what the principal may do, so +// the next request signed with one of these keys is re-authorized against +// the new access. An empty result means no cached key belonged to the +// principal — nothing local depended on its access. Idempotent: re-delivery +// of an invalidation, and an invalidation for a principal ingot has never +// seen, are both no-ops. +func (r *Revoker) InvalidatePrincipal(tenant did.DID, principal string) []did.DID { + affected := r.proofs.InvalidatePrincipal(PrincipalRef{Tenant: tenant, Principal: principal}) + r.clearKeys(affected, "iam: access key caches cleared by principal invalidation", + zap.Stringer("tenant", tenant), zap.String("principal", principal)) + return affected +} + +// clearKeys drops the verification key and tenant cached for each access +// key, which is what stops a local authorization: with no verification key +// the signature cannot be checked locally, and with no tenant the write +// path has nothing to encrypt to. Both caches are keyed by the accessKeyId +// — the key's did:key identifier with the prefix stripped (see +// GetUserAccountForRequest). +func (r *Revoker) clearKeys(affected []did.DID, reason string, fields ...zap.Field) { for _, key := range affected { - // The verification-key cache is keyed by the accessKeyId — the key's - // did:key identifier with the prefix stripped (see - // GetUserAccountForRequest). access := strings.TrimPrefix(key.String(), did.KeyPrefix) r.keys.Delete(access) r.tenants.Delete(access) - r.logger.Info("iam: access key caches cleared by revocation", - zap.Stringer("access", key), zap.Stringer("revoked", revoked)) + r.logger.Info(reason, append([]zap.Field{zap.Stringer("access", key)}, fields...)...) } - return affected } diff --git a/iam/revoker_test.go b/iam/revoker_test.go index f5b604a6..9395c3be 100644 --- a/iam/revoker_test.go +++ b/iam/revoker_test.go @@ -99,3 +99,47 @@ func TestRevokerUnknownCIDIsNoOp(t *testing.T) { _, ok := keys.Get(a.access, s3.KeyKindSigV4) require.True(t, ok) } + +// TestRevokerInvalidatePrincipal covers the firehose's second event kind: +// an invalidation names a (tenant, principal) pair, and every access key +// bound to it loses the state the local fast path reads. +func TestRevokerInvalidatePrincipal(t *testing.T) { + ctx := context.Background() + kp := iam.NewKeyProofs() + keys := iam.NewVerificationKeyCache() + tenants := iam.NewTenantCache() + r := iam.NewRevoker(kp, keys, tenants, nil) + + a := seedKey(t, kp, keys, tenants) + b := seedKey(t, kp, keys, tenants) + kp.Bind(a.key, iam.PrincipalRef{Tenant: a.tenant.DID(), Principal: "ada"}) + kp.Bind(b.key, iam.PrincipalRef{Tenant: b.tenant.DID(), Principal: "grace"}) + + affected := r.InvalidatePrincipal(a.tenant.DID(), "ada") + require.Equal(t, []did.DID{a.key}, affected) + + // Ada's key can no longer authorize locally: no chains, no verification + // key, no tenant. + chain, _, err := kp.For(a.key).ProofChain(ctx, a.dlg.Audience(), a.dlg.Command(), a.tenant.DID()) + require.NoError(t, err) + require.Empty(t, chain, "the invalidated principal's chains must not resolve") + _, ok := keys.Get(a.access, s3.KeyKindSigV4) + require.False(t, ok, "the invalidated principal's verification key must be gone") + _, ok = tenants.Get(a.access) + require.False(t, ok, "the invalidated principal's tenant must be gone") + + // Grace is untouched. + chain, _, err = kp.For(b.key).ProofChain(ctx, b.dlg.Audience(), b.dlg.Command(), b.tenant.DID()) + require.NoError(t, err) + require.Len(t, chain, 1, "another principal's chains must survive") + _, ok = keys.Get(b.access, s3.KeyKindSigV4) + require.True(t, ok) + _, ok = tenants.Get(b.access) + require.True(t, ok) + + // Re-delivery, and a principal ingot has never seen, are no-ops. + require.Empty(t, r.InvalidatePrincipal(a.tenant.DID(), "ada")) + require.Empty(t, r.InvalidatePrincipal(a.tenant.DID(), "nobody")) + _, ok = keys.Get(b.access, s3.KeyKindSigV4) + require.True(t, ok, "an unmatched invalidation must not touch cached state") +}