Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ require (
github.com/exaring/otelpgx v0.12.0
github.com/fil-forge/libforge v0.0.0-20260928151559-99540866a323
github.com/fil-forge/smelt v0.0.0-20260914083257-f594cf02655c
github.com/fil-forge/swarf v0.0.1-0.20260821142121-d5d1a0a56f00
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known and tracked: the pin is temporary while swarf#22 is open, and the re-pin to the merged revision follows its merge. The merge order is libforge #80, swarf #22, then this stack. Nothing to change in this PR until then.

github.com/fil-forge/ucantone v0.0.0-20260924160040-c31dec73d9b3
github.com/ipfs/go-cid v0.6.2
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ github.com/fil-forge/libforge v0.0.0-20260928151559-99540866a323 h1:2hZKA/dxRN7+
github.com/fil-forge/libforge v0.0.0-20260928151559-99540866a323/go.mod h1:sKml7ScjN2ov8F7jTuBIv823EgyWGTJG6cvA8ronPk4=
github.com/fil-forge/smelt v0.0.0-20260914083257-f594cf02655c h1:AEUEUEs/mDNksJIXdMgNMGJ03SHk6NlKjtDH58d1T04=
github.com/fil-forge/smelt v0.0.0-20260914083257-f594cf02655c/go.mod h1:OUO2GxgUihmYfywNIfvWTQocg6jqaNiTd04cG5BbdII=
github.com/fil-forge/swarf v0.0.1-0.20260821142121-d5d1a0a56f00 h1:uSJ2YCxOJQA7PDnDiBJAOstoJcw2IPnbpssKYCihsxg=
github.com/fil-forge/swarf v0.0.1-0.20260821142121-d5d1a0a56f00/go.mod h1:k1gay5Byjloml4k1SylxhRff1rMH4LcGKpbMEDLZR1o=
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c h1:uzLK4I8rD2SXBTeb/qOMbnfD/oAr2RoFVP730abzzyo=
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c/go.mod h1:sSe3eJWknrWxnldIpU46aWI2Rvq9PwiA3nUiSFn9Rxk=
github.com/fil-forge/ucantone v0.0.0-20260924160040-c31dec73d9b3 h1:Ta5ODMWR9mM4zMfRp/K1unAKvnt7f5dhehRA+YHiv7w=
github.com/fil-forge/ucantone v0.0.0-20260924160040-c31dec73d9b3/go.mod h1:aX35jlUs3hnWAhigjO8z9kMPiYnkW3XOW8iVoJyz7cg=
github.com/filecoin-project/go-data-segment v0.0.1 h1:1wmDxOG4ubWQm3ZC1XI5nCon5qgSq7Ra3Rb6Dbu10Gs=
Expand Down
80 changes: 80 additions & 0 deletions internal/testutil/swarf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package testutil

import (
"context"
"sync"

"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/ucan"
"github.com/ipfs/go-cid"
)

// Revocation is one delegation a [FakeSwarf] was asked to revoke.
type Revocation struct {
Revoker did.DID
Revoked cid.Cid
Delegation ucan.Delegation
}

// FakeSwarf stands in for the revocation service. It records every
// PublishBatch call, fails each one with Err while it is set, and runs
// OnPublish after each successful call.
type FakeSwarf struct {
Err error
OnPublish func()

mu sync.Mutex
batches [][]Revocation
}

func (f *FakeSwarf) PublishBatch(_ context.Context, revoker ucan.Issuer, revoked []ucan.Delegation) error {
if f.Err != nil {
return f.Err
}
b := make([]Revocation, 0, len(revoked))
for _, d := range revoked {
b = append(b, Revocation{Revoker: revoker.DID(), Revoked: d.Link(), Delegation: d})
}
f.mu.Lock()
f.batches = append(f.batches, b)
f.mu.Unlock()
if f.OnPublish != nil {
f.OnPublish()
}
return nil
}

// Reset forgets every recorded call.
func (f *FakeSwarf) Reset() {
f.mu.Lock()
defer f.mu.Unlock()
f.batches = nil
}

// Calls is the number of PublishBatch calls that succeeded.
func (f *FakeSwarf) Calls() int { return len(f.Batches()) }

// Batches returns the revocations of each successful call, in call order.
func (f *FakeSwarf) Batches() [][]Revocation {
f.mu.Lock()
defer f.mu.Unlock()
return append([][]Revocation(nil), f.batches...)
}

// Revocations returns every revocation across all calls.
func (f *FakeSwarf) Revocations() []Revocation {
var out []Revocation
for _, b := range f.Batches() {
out = append(out, b...)
}
return out
}

// Revoked returns the CID of every revoked delegation across all calls.
func (f *FakeSwarf) Revoked() []cid.Cid {
var out []cid.Cid
for _, r := range f.Revocations() {
out = append(out, r.Revoked)
}
return out
}
3 changes: 1 addition & 2 deletions pkg/api/access_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory"
"github.com/fil-forge/hilt/pkg/vault"
vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory"
swarfclient "github.com/fil-forge/swarf/pkg/client"
"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/did/plc"
"github.com/fil-forge/ucantone/multikey/secp256k1"
Expand All @@ -36,7 +35,7 @@ import (
// HTTP layer, not revocation (see the accesskey service tests for that).
type noopRevocations struct{}

func (noopRevocations) Publish(context.Context, ucan.Issuer, ucan.Delegation, ...swarfclient.PublishOption) error {
func (noopRevocations) PublishBatch(context.Context, ucan.Issuer, []ucan.Delegation) error {
return nil
}

Expand Down
135 changes: 47 additions & 88 deletions pkg/api/service/accesskey/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,15 @@ import (
"github.com/fil-forge/hilt/pkg/store/principal"
"github.com/fil-forge/hilt/pkg/store/tenant"
"github.com/fil-forge/hilt/pkg/vault"
swarfclient "github.com/fil-forge/swarf/pkg/client"
"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/multikey"
"github.com/fil-forge/ucantone/multikey/ed25519"
"github.com/fil-forge/ucantone/multikey/secp256k1"
"github.com/fil-forge/ucantone/ucan"
"github.com/fil-forge/ucantone/validator"
"github.com/multiformats/go-multibase"
"go.uber.org/zap"
)

const maxNameLength = 64

// RevocationPublisher is the subset of the revocation service (Swarf) that access
// key deletion needs. It is satisfied by [*swarfclient.Client]; the interface
// lets the logic be unit tested without a live revocation service.
type RevocationPublisher interface {
// Publish submits a /ucan/revoke invocation self-signed by revoker for the
// revoked delegation, which revoker must have issued unless a witness path is
// supplied with [swarfclient.WithWitnessPath].
Publish(ctx context.Context, revoker ucan.Issuer, revoked ucan.Delegation, opts ...swarfclient.PublishOption) error
}

// Service implements S3 access-key operations shared by the REST handlers.
type Service struct {
logger *zap.Logger
Expand All @@ -59,7 +45,7 @@ type Service struct {
policies bucketpolicystore.Store
delegations delegationstore.Store
secrets vault.Vault
revocations RevocationPublisher
revocations grant.RevocationPublisher
}

// New constructs the access-key service.
Expand All @@ -72,7 +58,7 @@ func New(
policies bucketpolicystore.Store,
delegations delegationstore.Store,
secrets vault.Vault,
revocations RevocationPublisher,
revocations grant.RevocationPublisher,
) *Service {
return &Service{
logger: logger,
Expand Down Expand Up @@ -144,7 +130,7 @@ func (s *Service) Create(ctx context.Context, externalID, name string, permissio
// Load the tenant signer up front: it is required to issue the key's
// delegations and its absence is unrecoverable, so fail before creating any
// state.
issuer, err := s.tenantIssuer(ctx, tenantRec.ID)
issuer, err := vault.TenantIssuer(ctx, s.secrets, tenantRec.ID)
if err != nil {
return accesskeystore.Record{}, "", err
}
Expand Down Expand Up @@ -276,7 +262,19 @@ func (s *Service) Create(ctx context.Context, externalID, name string, permissio
}
}
if len(dels) > 0 {
if err := s.delegations.PutBatch(ctx, dels); err != nil {
// Store the delegations under the access key's delegation lock rather
// than with PutBatch: a policy write rotating the principal's keys
// between the key record's commit and this write has already stored
// the key's delegations from the new policy, and the ones issued above
// may predate it. The access-key DID is freshly generated, so a key
// holding anything was rotated meanwhile, and the creation fails and
// rolls back rather than store stale grants beside it.
if err := s.delegations.Replace(ctx, []did.DID{accessKeyID}, func(_ context.Context, current map[did.DID][]ucan.Delegation) (map[did.DID][]ucan.Delegation, error) {
if len(current[accessKeyID]) > 0 {
return nil, fmt.Errorf("access key already holds delegations: %w", store.ErrInvalidArgument)
}
return map[did.DID][]ucan.Delegation{accessKeyID: dels}, nil
}); err != nil {
rollback()
return accesskeystore.Record{}, "", fmt.Errorf("storing delegations: %w", err)
}
Expand Down Expand Up @@ -349,11 +347,14 @@ func (s *Service) Get(ctx context.Context, externalID, accessKeyID string) (acce
}

// Delete revokes an access key belonging to the tenant: publishing UCAN
// revocations for its delegations, then removing those delegations, its vault
// key, and its record. Revocations are published first so that a revocation
// service failure leaves the key intact and the call cleanly retryable —
// otherwise the delegations would live on with nothing for a verifier to check.
// A principal-bound key's marker is revoked the same way.
// revocations for its delegations, then removing its record and those
// delegations under the key's delegation lock, and its vault key. Revocations
// are published first so that a revocation service failure leaves the key
// intact and the call cleanly retryable — otherwise the delegations would live
// on with nothing for a verifier to check. The record goes under the lock so a
// policy rotation that listed the key and is waiting on the lock finds it gone
// rather than issuing it fresh delegations. Both kinds of key are deleted the
// same way.
func (s *Service) Delete(ctx context.Context, externalID, accessKeyID string) error {
tenantRec, err := s.tenants.GetByExternalID(ctx, externalID)
if errors.Is(err, store.ErrRecordNotFound) {
Expand All @@ -373,79 +374,37 @@ func (s *Service) Delete(ctx context.Context, externalID, accessKeyID string) er
return fmt.Errorf("looking up access key: %w", err)
}

if err := s.revokeDelegations(ctx, tenantRec.ID, id); err != nil {
return err
}

if err := s.delegations.DeleteByAudience(ctx, id); err != nil {
return fmt.Errorf("deleting access key delegations: %w", err)
}
if err := s.secrets.Delete(ctx, vault.AccessKeyPath(tenantRec.ID, id)); err != nil {
s.logger.Warn("removing access key from vault", zap.Error(err))
}
if err := s.accessKeys.Delete(ctx, id); err != nil {
return fmt.Errorf("deleting access key: %w", err)
}
return nil
}

// revokeDelegations publishes a UCAN revocation for every delegation issued to
// the access key, signed by the tenant that issued them.
//
// No witness path accompanies them: the revocation service only requires one to
// prove authority over a delegation the revoker did not issue, and the tenant
// issues every delegation its access keys hold.
func (s *Service) revokeDelegations(ctx context.Context, tenantID, accessKeyID did.DID) error {
dels, err := store.Collect(ctx, func(ctx context.Context, opts store.PaginationConfig) (store.Page[ucan.Delegation], error) {
var listOpts []store.PaginationOption
if opts.Cursor != nil {
listOpts = append(listOpts, store.WithCursor(*opts.Cursor))
// The key's delegations are revoked and removed, and its record deleted,
// under the key's delegation lock and, on Postgres, in its transaction. A
// policy write rotating the key meanwhile serializes with it, and a failure
// after the row's delete leaves the record, its delegations and its secret
// in place for a retry. The tenant key is read only when there is
// something to revoke: a key holding no delegations is deleted without it.
log := s.logger.With(zap.Stringer("tenant", tenantRec.ID), zap.Stringer("access_key", id))
err = s.delegations.Replace(ctx, []did.DID{id}, func(ctx context.Context, current map[did.DID][]ucan.Delegation) (map[did.DID][]ucan.Delegation, error) {
if len(current[id]) > 0 {
issuer, err := vault.TenantIssuer(ctx, s.secrets, tenantRec.ID)
if err != nil {
return nil, err
}
if err := grant.PublishRevocations(ctx, log, s.revocations, issuer, current[id]); err != nil {
return nil, err
}
}
return s.delegations.ListByAudience(ctx, accessKeyID, listOpts...)
if err := s.accessKeys.Delete(ctx, id); err != nil {
return nil, fmt.Errorf("deleting access key: %w", err)
}
return nil, nil
Comment thread
pyropy marked this conversation as resolved.
})
Comment thread
pyropy marked this conversation as resolved.
if err != nil {
return fmt.Errorf("listing access key delegations: %w", err)
}
if len(dels) == 0 {
return nil
}

issuer, err := s.tenantIssuer(ctx, tenantID)
if err != nil {
return err
return fmt.Errorf("revoking access key: %w", err)
}
log := s.logger.With(zap.Stringer("tenant", tenantID), zap.Stringer("access_key", accessKeyID))

now := ucan.UnixTimestamp(time.Now().Unix())
for _, d := range dels {
// An expired delegation is rejected by the revocation service, and is
// unusable regardless, so revoking it is moot.
if err := validator.ValidateNotExpired(d, now); err != nil {
log.Info("skipping revocation of expired delegation", zap.Stringer("delegation", d.Link()))
continue
}
if err := s.revocations.Publish(ctx, issuer, d); err != nil {
return fmt.Errorf("publishing revocation for %s: %w", d.Link(), err)
}
log.Info("published revocation", zap.Stringer("delegation", d.Link()))
if err := s.secrets.Delete(ctx, vault.AccessKeyPath(tenantRec.ID, id)); err != nil {
s.logger.Warn("removing access key from vault", zap.Error(err))
}
return nil
}

// tenantIssuer loads the tenant's secp256k1 signing key from the vault and
// returns an issuer that signs as the tenant.
func (s *Service) tenantIssuer(ctx context.Context, tenantID did.DID) (ucan.Issuer, error) {
keyBytes, err := s.secrets.Read(ctx, vault.TenantKeyPath(tenantID))
if err != nil {
return nil, fmt.Errorf("reading tenant key: %w", err)
}
signer, err := secp256k1.Decode(keyBytes)
if err != nil {
return nil, fmt.Errorf("decoding tenant key: %w", err)
}
return multikey.NewIssuer(tenantID, signer), nil
}

// bucketNamesByID returns a DID→name map for the given bucket IDs owned by the
// tenant. IDs that don't resolve (e.g. a deleted bucket) are simply absent.
func (s *Service) bucketNamesByID(ctx context.Context, tenantID did.DID, ids []did.DID) (map[did.DID]string, error) {
Expand Down
Loading
Loading