oficina / home Public

Add atomic pending verification and activation operations

Paul Campbell pushed by paul@keymail.dev e20fd41110eaa7e57ad2ca334157476ff23d585e parent a9eba8a
6 files changed, +386 −3
  • internal/operator/directory.go +6 −0
  • internal/operator/directory_test.go +1 −1
  • internal/operator/protocol.go +1 −1
  • internal/operator/protocol_test.go +1 −1
  • internal/operator/verification.go +223 −0
  • internal/operator/verification_test.go +154 −0
diff --git a/internal/operator/directory.go b/internal/operator/directory.go
index de80ebb..c0e629c 100644
--- a/internal/operator/directory.go
+++ b/internal/operator/directory.go
@@ -51,6 +51,8 @@ type ProvisionInput struct {
}
type Result struct {
+ Attempt string `json:"attempt_id,omitempty"`
+ Expires int64 `json:"expires_at,omitempty"`
Version int `json:"version"`
OperationID string `json:"operation_id"`
Outcome string `json:"outcome"`
@@ -128,6 +130,10 @@ func (d *Directory) Execute(ctx context.Context, raw []byte) ([]byte, error) {
return d.seed(ctx, c, r)
case "directory.provision-consumer":
return d.provision(ctx, c, r)
+ case "directory.verify-consumer":
+ return d.verifyConsumer(ctx, c, r)
+ case "directory.activate-consumer":
+ return d.activate(ctx, c, r)
default:
return nil, ErrRefused
}
diff --git a/internal/operator/directory_test.go b/internal/operator/directory_test.go
index 6fb407e..33821db 100644
--- a/internal/operator/directory_test.go
+++ b/internal/operator/directory_test.go
@@ -26,7 +26,7 @@ func newDirectoryFixture(t *testing.T) *directoryFixture {
t.Helper()
f := newEngineFixture(t)
p := f.engine.verifier.pins
- p.Actions = []string{"directory.initialize", "directory.seed-team", "directory.provision-consumer"}
+ p.Actions = []string{"directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer"}
v, e := NewVerifier(p)
if e != nil {
t.Fatal(e)
diff --git a/internal/operator/protocol.go b/internal/operator/protocol.go
index d793d7a..37e1703 100644
--- a/internal/operator/protocol.go
+++ b/internal/operator/protocol.go
@@ -133,7 +133,7 @@ func allowedAction(role, a string) bool {
}
if role == "directory" {
switch a {
- case "directory.initialize", "directory.seed-team", "directory.provision-consumer":
+ case "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer":
return true
}
}
diff --git a/internal/operator/protocol_test.go b/internal/operator/protocol_test.go
index b4bf95e..ea0117b 100644
--- a/internal/operator/protocol_test.go
+++ b/internal/operator/protocol_test.go
@@ -131,7 +131,7 @@ func TestConfigurationAndUnsignedActionsRefuse(t *testing.T) {
t.Fatal("origin accepted", o)
}
}
- for _, action := range []string{"directory.activate-consumer", "directory.verify-consumer", "directory.restore", "raw.sql"} {
+ for _, action := range []string{"directory.restore", "raw.sql"} {
p := v.pins
p.Actions = []string{action}
if _, e := NewVerifier(p); e != ErrRefused {
diff --git a/internal/operator/verification.go b/internal/operator/verification.go
new file mode 100644
index 0000000..b7e9aea
--- /dev/null
+++ b/internal/operator/verification.go
@@ -0,0 +1,223 @@
+package operator
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "errors"
+ "net/url"
+
+ "amadan.net/oficina/home/internal/home/directorydata"
+)
+
+type VerificationInput struct {
+ InputBase
+ Consumer string `json:"consumer"`
+ TeamSQID string `json:"team_sqid"`
+ App string `json:"app"`
+ Audience string `json:"audience"`
+ Generation string `json:"generation"`
+ RecordSequence int64 `json:"record_sequence"`
+ RecordDigest string `json:"record_digest"`
+ Epoch int64 `json:"expected_epoch"`
+ Owner string `json:"owner_ref"`
+ Issued int64 `json:"issued_at"`
+ Expires int64 `json:"expires_at"`
+}
+type ActivationEvidence struct {
+ Version int `json:"version"`
+ Policy string `json:"policy"`
+ Attempt string `json:"attempt_id"`
+ Consumer string `json:"consumer"`
+ TeamSQID string `json:"team_sqid"`
+ App string `json:"app"`
+ Audience string `json:"audience"`
+ Generation string `json:"generation"`
+ RecordSequence int64 `json:"record_sequence"`
+ RecordDigest string `json:"record_digest"`
+ Epoch int64 `json:"epoch"`
+ Owner string `json:"owner_ref"`
+ AppVersion string `json:"app_version"`
+ ArtifactDigest string `json:"artifact_digest"`
+ EnvironmentBinding string `json:"environment_binding"`
+ ManifestDigest string `json:"credential_manifest_digest"`
+ AliasHost string `json:"alias_host"`
+ Started int64 `json:"started_at"`
+ Completed int64 `json:"completed_at"`
+}
+type ActivationInput struct {
+ InputBase
+ Evidence ActivationEvidence `json:"evidence"`
+}
+
+type pendingBinding struct {
+ teamID, epoch int64
+ label string
+ sequence int64
+ generation string
+ digest []byte
+}
+
+func (d *Directory) pending(ctx context.Context, c *sql.Conn, consumer, team, app, audience, gen string, sequence int64, recordDigest, owner string, epoch int64) (pendingBinding, error) {
+ fail := func() (pendingBinding, error) { return pendingBinding{}, ErrRefused }
+ expected, ok := canonical(recordDigest, 32)
+ if !atom(consumer, 64) || !d.apps[app] || !Origin(audience) || !CanonicalToken(gen) || sequence <= 0 || !ok || !PersonRef(owner) || epoch <= 0 {
+ return fail()
+ }
+ var b pendingBinding
+ var storedTeam, storedApp, storedOrigin, state string
+ var enabled int
+ err := c.QueryRowContext(ctx, `SELECT t.id,t.sqid,t.label,t.roster_epoch,i.app,i.origin,i.state,c.enabled
+ FROM roster_consumers c JOIN team_instances i ON i.consumer_id=c.consumer_id AND i.team_id=c.team_id AND i.app=c.app AND i.origin=c.origin
+ JOIN teams t ON t.id=c.team_id WHERE c.consumer_id=?`, consumer).Scan(&b.teamID, &storedTeam, &b.label, &b.epoch, &storedApp, &storedOrigin, &state, &enabled)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return fail()
+ }
+ return pendingBinding{}, ErrUnavailable
+ }
+ if enabled != 1 || state != "pending" || storedTeam != team || storedApp != app || storedOrigin != audience || b.epoch != epoch {
+ return fail()
+ }
+ if err = generation(ctx, c, gen); err != nil {
+ return pendingBinding{}, err
+ }
+ var receiptSequence int64
+ var receiptGeneration, receiptConsumer, kind string
+ var receiptDigest []byte
+ err = c.QueryRowContext(ctx, `SELECT p.sequence,p.generation,p.record_digest,r.sequence,r.generation,r.record_digest,r.consumer_id,r.operation
+ FROM directory_operator_consumer_records p JOIN roster_operator_receipts r ON r.operation_id=p.operation_id WHERE p.consumer_id=?`, consumer).Scan(&b.sequence, &b.generation, &b.digest, &receiptSequence, &receiptGeneration, &receiptDigest, &receiptConsumer, &kind)
+ if err != nil {
+ return pendingBinding{}, ErrUnavailable
+ }
+ if b.sequence != sequence || b.generation != gen || !bytes.Equal(b.digest, expected) || receiptSequence != b.sequence || receiptGeneration != b.generation || !bytes.Equal(receiptDigest, b.digest) || receiptConsumer != consumer || kind != "provision" {
+ return fail()
+ }
+ var found int
+ if err = c.QueryRowContext(ctx, "SELECT 1 FROM memberships WHERE team_id=? AND person_ref=? AND role='owner'", b.teamID, owner).Scan(&found); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return fail()
+ }
+ return pendingBinding{}, ErrUnavailable
+ }
+ var currentTokens int
+ if err = c.QueryRowContext(ctx, "SELECT count(*) FROM roster_tokens WHERE consumer_id=? AND slot='current' AND expires_at IS NULL AND issued_at<=?", consumer, d.engine.now().Unix()).Scan(&currentTokens); err != nil {
+ return pendingBinding{}, ErrUnavailable
+ }
+ if currentTokens != 1 {
+ return fail()
+ }
+ if !d.policy.ValidateRoute(directoryRoute(b, team, app, audience, consumer)) {
+ return fail()
+ }
+ return b, nil
+}
+func (d *Directory) verifyConsumer(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) {
+ var in VerificationInput
+ if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial {
+ return nil, ErrRefused
+ }
+ now := d.engine.now().Unix()
+ if in.Issued <= 0 || in.Issued > now || in.Expires <= now || in.Expires <= in.Issued || in.Expires-in.Issued > 600 {
+ return nil, ErrRefused
+ }
+ binding, e := d.pending(ctx, c, in.Consumer, in.TeamSQID, in.App, in.Audience, in.Generation, in.RecordSequence, in.RecordDigest, in.Owner, in.Epoch)
+ if e != nil {
+ return nil, e
+ }
+ var old, oldConsumer string
+ var issued, expires int64
+ e = c.QueryRowContext(ctx, "SELECT a.operation_id,a.consumer_id,a.issued_at,a.expires_at FROM verification_active v JOIN verification_attempts a ON a.operation_id=v.operation_id WHERE v.consumer_id=?", in.Consumer).Scan(&old, &oldConsumer, &issued, &expires)
+ if e == nil {
+ if oldConsumer != in.Consumer || issued <= 0 || issued > now || expires <= issued || expires-issued > 600 || expires > now {
+ return nil, ErrRefused
+ }
+ if _, e = c.ExecContext(ctx, "DELETE FROM verification_active WHERE consumer_id=? AND operation_id=?", in.Consumer, old); e != nil {
+ return nil, e
+ }
+ if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'expired',?)", old, r.authorization.OperationID, now); e != nil {
+ return nil, e
+ }
+ } else if !errors.Is(e, sql.ErrNoRows) {
+ return nil, ErrUnavailable
+ }
+ hash, ok := canonical(r.Head(), 32)
+ if !ok {
+ return nil, ErrUnavailable
+ }
+ _, e = c.ExecContext(ctx, "INSERT INTO verification_attempts VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", r.authorization.OperationID, in.Consumer, binding.teamID, in.App, in.Audience, in.Generation, in.RecordSequence, binding.digest, in.Epoch, in.Owner, in.Issued, in.Expires, hash)
+ if e != nil {
+ return nil, e
+ }
+ if _, e = c.ExecContext(ctx, "INSERT INTO verification_active VALUES(?,?)", in.Consumer, r.authorization.OperationID); e != nil {
+ return nil, e
+ }
+ if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'opened',?)", r.authorization.OperationID, r.authorization.OperationID, now); e != nil {
+ return nil, e
+ }
+ result := resultFor(r)
+ result.Attempt = r.authorization.OperationID
+ result.Expires = in.Expires
+ return encode(result)
+}
+func version(s string) bool {
+ if len(s) != 40 {
+ return false
+ }
+ for _, c := range s {
+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
+ return false
+ }
+ }
+ return true
+}
+func (d *Directory) activate(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) {
+ var in ActivationInput
+ if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial {
+ return nil, ErrRefused
+ }
+ p := in.Evidence
+ now := d.engine.now().Unix()
+ u, e := url.Parse(p.Audience)
+ if e != nil || p.Version != 1 || p.Policy != "oficina/team-activation/v1" || !CanonicalToken(p.Attempt) || !version(p.AppVersion) || !CanonicalToken(p.ArtifactDigest) || !CanonicalToken(p.EnvironmentBinding) || !CanonicalToken(p.ManifestDigest) || p.AliasHost != u.Host || p.Started <= 0 || p.Completed < p.Started || p.Completed > now || now-p.Completed >= 300 {
+ return nil, ErrRefused
+ }
+ binding, e := d.pending(ctx, c, p.Consumer, p.TeamSQID, p.App, p.Audience, p.Generation, p.RecordSequence, p.RecordDigest, p.Owner, p.Epoch)
+ if e != nil {
+ return nil, e
+ }
+ var consumer, app, origin, gen, owner string
+ var teamID, sequence, epoch, issued, expires int64
+ var recordDigest []byte
+ e = c.QueryRowContext(ctx, `SELECT a.consumer_id,a.team_id,a.app,a.origin,a.generation,a.record_sequence,a.record_digest,a.expected_epoch,a.owner_ref,a.issued_at,a.expires_at
+ FROM verification_active v JOIN verification_attempts a ON a.operation_id=v.operation_id WHERE v.consumer_id=? AND a.operation_id=?`, p.Consumer, p.Attempt).Scan(&consumer, &teamID, &app, &origin, &gen, &sequence, &recordDigest, &epoch, &owner, &issued, &expires)
+ if e != nil {
+ if errors.Is(e, sql.ErrNoRows) {
+ return nil, ErrRefused
+ }
+ return nil, ErrUnavailable
+ }
+ if consumer != p.Consumer || teamID != binding.teamID || app != p.App || origin != p.Audience || gen != p.Generation || sequence != p.RecordSequence || !bytes.Equal(recordDigest, binding.digest) || epoch != p.Epoch || owner != p.Owner || issued <= 0 || issued > now || expires <= now || expires <= issued || expires-issued > 600 || p.Started < issued || p.Completed >= expires {
+ return nil, ErrRefused
+ }
+ if _, e = c.ExecContext(ctx, "UPDATE team_instances SET state='live' WHERE consumer_id=? AND state='pending'", p.Consumer); e != nil {
+ return nil, e
+ }
+ if _, e = c.ExecContext(ctx, "DELETE FROM verification_active WHERE consumer_id=? AND operation_id=?", p.Consumer, p.Attempt); e != nil {
+ return nil, e
+ }
+ if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'activated',?)", p.Attempt, r.authorization.OperationID, now); e != nil {
+ return nil, e
+ }
+ result := resultFor(r)
+ result.Attempt = p.Attempt
+ result.Generation = p.Generation
+ result.TeamID = binding.teamID
+ result.TeamSQID = p.TeamSQID
+ result.Epoch = p.Epoch
+ return encode(result)
+}
+
+func directoryRoute(b pendingBinding, team, app, audience, consumer string) directorydata.Route {
+ return directorydata.Route{TeamID: b.teamID, TeamSQID: team, Label: b.label, App: app, Origin: audience, ConsumerID: consumer}
+}
diff --git a/internal/operator/verification_test.go b/internal/operator/verification_test.go
new file mode 100644
index 0000000..bcb9262
--- /dev/null
+++ b/internal/operator/verification_test.go
@@ -0,0 +1,154 @@
+package operator
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/go-json-experiment/json"
+)
+
+func pendingFixture(t *testing.T) (*directoryFixture, VerificationInput) {
+ t.Helper()
+ f := newDirectoryFixture(t)
+ f.initialize(t)
+ if _, err := f.seed(t, 1, "alpha"); err != nil {
+ t.Fatal(err)
+ }
+ raw := f.input(t, "directory.provision-consumer", func(b InputBase) any {
+ return ProvisionInput{b, token(10), "Uk", "docs", "alpha-docs", "https://alpha.docs.oficina.works", 0}
+ })
+ if _, err := f.directory.Execute(context.Background(), raw); err != nil {
+ t.Fatal(err)
+ }
+ var digest []byte
+ if err := f.db.QueryRow("SELECT record_digest FROM directory_operator_consumer_records").Scan(&digest); err != nil {
+ t.Fatal(err)
+ }
+ return f, VerificationInput{Consumer: "alpha-docs", TeamSQID: "Uk", App: "docs", Audience: "https://alpha.docs.oficina.works", Generation: token(10), RecordSequence: 1, RecordDigest: base64.RawURLEncoding.EncodeToString(digest), Epoch: 2, Owner: "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", Issued: f.now.Unix(), Expires: f.now.Unix() + 600}
+}
+func openWindow(t *testing.T, f *directoryFixture, in VerificationInput) ([]byte, []byte, Result) {
+ t.Helper()
+ raw := f.input(t, "directory.verify-consumer", func(b InputBase) any { in.InputBase = b; return in })
+ out, err := f.directory.Execute(context.Background(), raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var result Result
+ if err = json.Unmarshal(out, &result); err != nil {
+ t.Fatal(err)
+ }
+ return raw, out, result
+}
+func activation(in VerificationInput, attempt string) ActivationEvidence {
+ return ActivationEvidence{Version: 1, Policy: "oficina/team-activation/v1", Attempt: attempt, Consumer: in.Consumer, TeamSQID: in.TeamSQID, App: in.App, Audience: in.Audience, Generation: in.Generation, RecordSequence: in.RecordSequence, RecordDigest: in.RecordDigest, Epoch: in.Epoch, Owner: in.Owner, AppVersion: strings.Repeat("a", 40), ArtifactDigest: token(80), EnvironmentBinding: token(81), ManifestDigest: token(82), AliasHost: "alpha.docs.oficina.works", Started: in.Issued, Completed: in.Issued}
+}
+func TestVerificationAbsoluteExpiryAndExactReplay(t *testing.T) {
+ f, in := pendingFixture(t)
+ raw, out, result := openWindow(t, f, in)
+ *f.now = f.now.Add(600 * time.Second)
+ replay, err := f.directory.Execute(context.Background(), raw)
+ if err != nil || !bytes.Equal(replay, out) || result.Expires != in.Expires {
+ t.Fatal("expired replay changed window", err)
+ }
+ in.Issued = f.now.Unix()
+ in.Expires = in.Issued + 600
+ _, _, next := openWindow(t, f, in)
+ if next.Attempt == result.Attempt || countRows(t, f.db, "verification_attempts") != 2 || countRows(t, f.db, "verification_active") != 1 || countRows(t, f.db, "verification_outcomes") != 3 {
+ t.Fatal("replacement lost immutable history")
+ }
+ raw = f.input(t, "directory.verify-consumer", func(b InputBase) any { in.InputBase = b; return in })
+ if _, err = f.directory.Execute(context.Background(), raw); err != ErrRefused {
+ t.Fatal("overlap renewed window", err)
+ }
+}
+func TestVerificationRejectsWrongScopeBeforeStanding(t *testing.T) {
+ mutations := map[string]func(*VerificationInput){
+ "long": func(v *VerificationInput) { v.Expires++ }, "expired": func(v *VerificationInput) { v.Expires = v.Issued }, "future": func(v *VerificationInput) { v.Issued++; v.Expires++ },
+ "epoch": func(v *VerificationInput) { v.Epoch++ }, "owner": func(v *VerificationInput) { v.Owner = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" }, "record": func(v *VerificationInput) { v.RecordDigest = token(99) }, "generation": func(v *VerificationInput) { v.Generation = token(99) }, "audience": func(v *VerificationInput) { v.Audience = "https://beta.docs.oficina.works" },
+ }
+ for name, mutate := range mutations {
+ t.Run(name, func(t *testing.T) {
+ f, in := pendingFixture(t)
+ mutate(&in)
+ raw := f.input(t, "directory.verify-consumer", func(b InputBase) any { in.InputBase = b; return in })
+ if _, err := f.directory.Execute(context.Background(), raw); err != ErrRefused {
+ t.Fatal(err)
+ }
+ if countRows(t, f.db, "verification_attempts") != 0 || countRows(t, f.db, "verification_active") != 0 {
+ t.Fatal("rejected attempt granted standing")
+ }
+ })
+ }
+}
+func TestActivationExactEvidenceAndAtomicReplay(t *testing.T) {
+ f, in := pendingFixture(t)
+ _, _, window := openWindow(t, f, in)
+ evidence := activation(in, window.Attempt)
+ raw := f.input(t, "directory.activate-consumer", func(b InputBase) any { return ActivationInput{b, evidence} })
+ out, err := f.directory.Execute(context.Background(), raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ replay, err := f.directory.Execute(context.Background(), raw)
+ if err != nil || !bytes.Equal(out, replay) {
+ t.Fatal("activation replay", err)
+ }
+ var state string
+ if err = f.db.QueryRow("SELECT state FROM team_instances").Scan(&state); err != nil || state != "live" || countRows(t, f.db, "verification_active") != 0 || countRows(t, f.db, "verification_outcomes") != 2 {
+ t.Fatal("incomplete activation", err)
+ }
+}
+func TestActivationRejectsChangedStateAndEvidence(t *testing.T) {
+ cases := map[string]func(*testing.T, *directoryFixture, *ActivationEvidence){
+ "token": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) {
+ if _, e := f.db.Exec("DELETE FROM roster_tokens"); e != nil {
+ t.Fatal(e)
+ }
+ },
+ "alias": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) {
+ p.AliasHost = "beta.docs.oficina.works"
+ },
+ "attempt": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { p.Attempt = token(95) },
+ "version": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { p.AppVersion = "latest" },
+ "future": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { p.Completed++ },
+ "expired": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) {
+ *f.now = f.now.Add(600 * time.Second)
+ p.Completed = f.now.Unix()
+ },
+ "epoch": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) {
+ if _, e := f.db.Exec("INSERT INTO memberships(team_id,person_ref,role) VALUES(1,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')"); e != nil {
+ t.Fatal(e)
+ }
+ },
+ "owner": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) {
+ if _, e := f.db.Exec("DELETE FROM memberships"); e != nil {
+ t.Fatal(e)
+ }
+ },
+ "pointer": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) {
+ if _, e := f.db.Exec("UPDATE directory_operator_consumer_records SET sequence=2"); e != nil {
+ t.Fatal(e)
+ }
+ },
+ }
+ for name, mutate := range cases {
+ t.Run(name, func(t *testing.T) {
+ f, in := pendingFixture(t)
+ _, _, window := openWindow(t, f, in)
+ p := activation(in, window.Attempt)
+ mutate(t, f, &p)
+ raw := f.input(t, "directory.activate-consumer", func(b InputBase) any { return ActivationInput{b, p} })
+ if _, err := f.directory.Execute(context.Background(), raw); err != ErrRefused {
+ t.Fatal(err)
+ }
+ var state string
+ if err := f.db.QueryRow("SELECT state FROM team_instances").Scan(&state); err != nil || state != "pending" || countRows(t, f.db, "verification_active") != 1 || countRows(t, f.db, "verification_outcomes") != 1 {
+ t.Fatal("failed activation mutated", err)
+ }
+ })
+ }
+}