diff --git a/README.md b/README.md index fceeb5e..ca173c0 100644 --- a/README.md +++ b/README.md @@ -934,6 +934,10 @@ delegation is refused and `GetDelegation` answers `active: false`. Unlike a revoke, an expiry advances no revision. A newly issued capability's signed expiry is bounded by both `MaxTTL` and the delegation's remaining lifetime, including when issuing again near the end; signing that crosses the bound is refused. +For finite module and client delegations, the remaining lifetime and root +mint use one issuance instant: time spent reading revision/seal state cannot +extend that signed window. Verification, child derivation and the post-signing +grant checks still use the live clock, so an expired or shortened grant refuses. Core's verifier still applies its 30-second clock tolerance. Capabilities minted before this bound was introduced retain their original signed expiry. The edge requires a lifetime of a diff --git a/libraries/authority-core/go/authority/client.go b/libraries/authority-core/go/authority/client.go index a1b485d..bd5a923 100644 --- a/libraries/authority-core/go/authority/client.go +++ b/libraries/authority-core/go/authority/client.go @@ -148,15 +148,12 @@ func (s *Service) IssueForClientDelegation(ctx context.Context, id, clientID, re if err != nil { return Issued{}, err } - ttl := min(clamp(r.TTL, s.cfg.MaxTTL), d.ExpiresAt.Sub(s.cfg.Clock())) - if ttl <= 0 { - return Issued{}, domain.Refuse("client delegation expired") - } - root, err := s.startFenced(ctx, f, workcontext.StartInput{ + ttl := clamp(r.TTL, s.cfg.MaxTTL) + root, err := s.startFencedUntil(ctx, f, workcontext.StartInput{ TenantID: d.Organization, OrganizationID: d.Organization, OwnerPrincipalID: d.Grantor, OwnerPrincipalKind: "human", TaskID: s.cfg.NewID(), Audience: audience.Principal, AuthorityScopes: scopes, InstallationID: inst.ID, TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent, - }) + }, d.ExpiresAt) if err != nil { return Issued{}, err } diff --git a/libraries/authority-core/go/authority/delegation.go b/libraries/authority-core/go/authority/delegation.go index 74fd6b0..278bae9 100644 --- a/libraries/authority-core/go/authority/delegation.go +++ b/libraries/authority-core/go/authority/delegation.go @@ -223,23 +223,13 @@ func (s *Service) IssueForDelegation(ctx context.Context, id string, actor Workl if err != nil { return Issued{}, err } - // Refreshing near a delegation's end must not buy another MaxTTL. Bound - // the signed capability itself, not just the OAuth response's expires_in. + // Bound the root's signed window at one instant; children cannot outlive it. ttl := clamp(r.TTL, s.cfg.MaxTTL) - if !d.ExpiresAt.IsZero() { - remaining := d.ExpiresAt.Sub(s.cfg.Clock()) - if remaining <= 0 { - return Issued{}, domain.Refuse("delegation %s expired", id) - } - if remaining < ttl { - ttl = remaining - } - } - root, err := s.startFenced(ctx, f, workcontext.StartInput{ + root, err := s.startFencedUntil(ctx, f, workcontext.StartInput{ TenantID: d.Organization, OrganizationID: d.Organization, OwnerPrincipalID: d.Grantor, OwnerPrincipalKind: "human", TaskID: s.cfg.NewID(), Audience: audience.Principal, AuthorityScopes: scope.Limits{}.Project(through, scopes), InstallationID: sealTo, TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent, - }) + }, d.ExpiresAt) if err != nil { return Issued{}, err } diff --git a/libraries/authority-core/go/authority/delegation_clock_test.go b/libraries/authority-core/go/authority/delegation_clock_test.go new file mode 100644 index 0000000..4e02a1e --- /dev/null +++ b/libraries/authority-core/go/authority/delegation_clock_test.go @@ -0,0 +1,106 @@ +package authority_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" +) + +// A real store can take time reading the mint's revision and seal after the +// delegation decision. Advance the injected clock there, without sleeps. +type mintDelayStore struct { + authority.Store + advance func() +} + +type mintDelayTx struct { + authority.Tx + store *mintDelayStore +} + +func (s *mintDelayStore) Read(ctx context.Context, fn func(authority.Tx) error) error { + return s.Store.Read(ctx, func(tx authority.Tx) error { return fn(mintDelayTx{tx, s}) }) +} + +func (tx mintDelayTx) AuthorizationRevision(org string) (uint64, error) { + if advance := tx.store.advance; advance != nil { + tx.store.advance = nil + advance() + } + return tx.Tx.AuthorizationRevision(org) +} + +func TestDelegationDeadlineSurvivesMintReadLatency(t *testing.T) { + for _, client := range []bool{false, true} { + for _, delay := range []time.Duration{0, 100 * time.Millisecond, 1500 * time.Millisecond, 61 * time.Second} { + for _, requested := range []time.Duration{0, 10 * time.Second} { + t.Run(fmt.Sprintf("client=%t/delay=%s/ttl=%s", client, delay, requested), func(t *testing.T) { + h := newHarness(t) + h.now = h.now.Add(950 * time.Millisecond) + h.grant("a", "viewer", "maya") + scopes := authority.Scopes{owned(kind, h.principal(acme, "a"), read)} + var d domain.Delegation + var err error + if client { + c, err := h.svc.RegisterClient(h.ctx, "External client", []string{"https://client.invalid/callback"}) + if err != nil { + t.Fatal(err) + } + d, err = h.svc.DelegateToClient(h.ctx, h.person("maya"), c.ID, "a", h.cfg.Issuer+"/mcp/a", scopes, "bounded run", 60) + } else { + d, err = h.svc.Delegate(h.ctx, h.person("maya"), "a", scopes, "bounded run", 60) + } + if err != nil { + t.Fatal(err) + } + started := h.now + store := &mintDelayStore{Store: h.store} + newID := h.cfg.NewID + advanced := false + // The task ID is requested after the grant decision and before + // Core.Start reads its revision. Earlier decision reads stay live. + h.cfg.NewID = func() string { + store.advance = func() { h.now = h.now.Add(delay); advanced = true } + return newID() + } + svc, err := authority.New(store, h.cfg) + if err != nil { + t.Fatal(err) + } + var out authority.Issued + if client { + out, err = svc.IssueForClientDelegation(h.ctx, d.ID, d.ClientID, d.Resource, authority.IssueRequest{TTL: requested}) + } else { + out, err = svc.IssueForDelegation(h.ctx, d.ID, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store", TTL: requested}) + } + if !advanced { + t.Fatal("test did not reach the mint revision read") + } + if delay >= time.Minute { + if err == nil || out.Token != "" { + t.Fatal("issuance returned authority after the delegation expired during minting") + } + return + } + if err != nil { + t.Fatalf("a live delegation should issue despite mint read latency: %v", err) + } + want := d.ExpiresAt.Unix() + if requested > 0 { + want = started.Add(requested).Unix() + } + if got := out.Context.GetExpiresAtUnix(); got != want { + t.Fatalf("signed expiry = %d, want %d; mint latency must not extend the window", got, want) + } + if err := h.check(out); err != nil { + t.Fatal(err) + } + }) + } + } + } +} diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index 30bc8b1..3a99e6f 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -166,7 +166,27 @@ func (s *Service) audienceOf(tx Tx, r *IssueRequest, actor string) error { // uninstall committed in between must not turn old scopes into fresh // authority. func (s *Service) startFenced(ctx context.Context, f fence, in workcontext.StartInput) (Issued, error) { - token, signed, err := s.authority.Start(ctx, in) + return s.startFencedUntil(ctx, f, in, time.Time{}) +} + +// startFencedUntil gives a bounded root one issuance instant. Core.Start reads +// revision/seal state before its clock; adding an earlier remaining duration to +// that later instant would move the deadline. Only this mint uses the snapshot: +// verification, child derivation and post-signing grant checks keep the live clock. +func (s *Service) startFencedUntil(ctx context.Context, f fence, in workcontext.StartInput, deadline time.Time) (Issued, error) { + minter := s.authority + if !deadline.IsZero() { + now := s.cfg.Clock() + remaining := deadline.Sub(now) + if remaining <= 0 { + return Issued{}, domain.Refuse("delegation expired") + } + in.TTL = min(in.TTL, remaining) + bounded := *s.authority + bounded.Now = func() time.Time { return now } + minter = &bounded + } + token, signed, err := minter.Start(ctx, in) if err != nil { return Issued{}, err }