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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions libraries/authority-core/go/authority/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 3 additions & 13 deletions libraries/authority-core/go/authority/delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
106 changes: 106 additions & 0 deletions libraries/authority-core/go/authority/delegation_clock_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
}
}
22 changes: 21 additions & 1 deletion libraries/authority-core/go/authority/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading