Agent access
Approve a separate assistant for a preview site. Access expires automatically and can be revoked here. Your personal account and login stay separate.
+diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e20fb54..832f2d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,19 @@ env: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: agentidentity_test + POSTGRES_USER: agentidentity_test + POSTGRES_PASSWORD: isolated-test-only + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U agentidentity_test -d agentidentity_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + AGENTIDENTITY_TEST_DATABASE_URL: postgres://agentidentity_test:isolated-test-only@127.0.0.1:5432/agentidentity_test?sslmode=disable steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -23,7 +36,7 @@ jobs: if [ -n "$RELEASES_TOKEN" ]; then git config --global url."https://x-access-token:${RELEASES_TOKEN}@github.com/".insteadOf "https://github.com/" fi - - run: go test -race ./... + - run: go test -race -v ./... - run: go vet ./... plugin-contracts: diff --git a/CHANGELOG.md b/CHANGELOG.md index 721f138..e8e8c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] ### Added +- Opt-in native `agentidentity` service grants, registered RFC7523 exchange, + strict tenant/resource/field authorization, owner approval/revocation UI and + declarative multi-preview configuration and explicit PostgreSQL persistence + with atomic replay/revocation and retained audit; no human-session or issuer-key reuse. - README verified-status banner per workflow#714 (multi-repo QoL sweep). - CONTRIBUTING.md, examples/minimal/config.yaml, and GitHub issue/PR templates. - Admin auth config now exposes a `passkey_auth_enabled` toggle, and auth diff --git a/README.md b/README.md index edfa29f..9d3922b 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,19 @@ true`. ## Auth Use Cases & Combinations +Tenant-scoped agent access is proposed in +[the agent access design](docs/plans/2026-10-09-tenant-scoped-agent-access-design.md). +It documents current component gaps and an owner-approved, revocable grant with +short-lived access. It is not a deployed capability or permission to reuse a +personal admin session. + +The opt-in native Go [agentidentity package](docs/agentidentity/README.md) +implements registered grants, assertion exchange, strict service-token +authorization and reusable owner management. Per-preview policies are +declarative; adding a site does not require a code fork. Its durable host +adapters, managed Workflow issuer and matched staging adoption must be reviewed +and explicitly activated before any credentials or grants are created. + `workflow-plugin-auth` is a library of stateless auth primitives; complete auth flows are composed from these steps plus the engine's built-in `auth.*` modules and the provider plugins. Which combination covers which use case: diff --git a/agentidentity/http.go b/agentidentity/http.go new file mode 100644 index 0000000..17e80fb --- /dev/null +++ b/agentidentity/http.go @@ -0,0 +1,333 @@ +package agentidentity + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "io" + "mime" + "net/http" + "net/url" + "strings" + "time" +) + +// ExchangeHandler handles only the exact configured RFC7523 token endpoint. +// There is no client-secret, cookie, human-session or refresh-token fallback. +// The TLS/reverse-proxy gate is host-owned and MUST reject untrusted forwarding +// metadata. Do not mount Workflow's generic OAuth issuer beside this handler. +func (s *Service) ExchangeHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + privateHeaders(w) + endpoint, _ := parseHTTPS(s.options.TokenEndpoint) + if r.URL.Path != endpoint.Path || r.URL.RawPath != "" || r.URL.RawQuery != "" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || content != "application/x-www-form-urlencoded" || r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + httpError(w, ErrDenied) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 20000) + if err = r.ParseForm(); err != nil { + httpError(w, ErrDenied) + return + } + for key, values := range r.PostForm { + if len(values) != 1 || (key != "grant_type" && key != "grant_id" && key != "assertion" && key != "audience" && key != "scope") { + httpError(w, ErrDenied) + return + } + } + if r.PostForm.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" { + httpError(w, ErrDenied) + return + } + scopes := strings.Fields(r.PostForm.Get("scope")) + if strings.Join(scopes, " ") != r.PostForm.Get("scope") { + httpError(w, ErrDenied) + return + } + response, err := s.Exchange(r.Context(), ExchangeRequest{GrantID: r.PostForm.Get("grant_id"), Assertion: r.PostForm.Get("assertion"), Audience: r.PostForm.Get("audience"), Scopes: scopes}) + if err != nil { + httpError(w, err) + return + } + _ = json.NewEncoder(w).Encode(response) + }) +} + +type ManagementOptions struct { + Path string + OwnerOrigin string + PagePath string + Owners OwnerPolicy + // Tenants supplies immutable owner-UI declarations when Policy is wrapped + // by a host's current preview gate. It confers no authority: every visible + // proposal must also pass the service's required Policy and OwnerPolicy. + // If omitted, the UI uses Policy directly when it is a *TenantPolicy. + Tenants *TenantPolicy +} + +type proposal struct { + Subject string `json:"subject"` + AssertionIssuer string `json:"assertion_issuer"` + KeyID string `json:"key_id"` + PublicKeyPEM string `json:"public_key_pem"` + TenantID int64 `json:"tenant_id"` + ExpiresAt time.Time `json:"expires_at"` + APIAudience string `json:"api_audience"` + Scopes []string `json:"scopes"` + UpdatePageIDs []string `json:"update_page_ids"` + Preview *PreviewGrant `json:"preview"` + // The policy adapter must ALSO authorize the exact requested boundary. + Confirmed bool `json:"confirmed"` +} + +// ManagementHandler owns reusable registration/list/revocation behavior. +// Mount only behind existing HUMAN owner authorization and CSRF protection. +// Its typed resolver/policy cannot be replaced with a service principal. Hosts +// are responsible for a durable transaction plus audit/outbox for management +// mutations; no rollback can safely erase an already applied revoke. +func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { + // Management and validation must use the same authoritative adapter. A + // separately configured manager could report a successful revoke while the + // store used by Authorize still admits the old grant and its tokens. + manager, ok := s.options.Store.(GrantManager) + if !ok || o.Owners == nil || !managementPath(o.Path) || !httpsOrigin(o.OwnerOrigin) { + return nil, ErrUnavailable + } + if o.PagePath != "" && (!managementPagePath(o.PagePath) || o.PagePath == o.Path) { + return nil, ErrUnavailable + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + privateHeaders(w) + if r.URL.RawPath != "" || r.URL.RawQuery != "" { + http.NotFound(w, r) + return + } + if r.URL.Path != o.Path && r.URL.Path != o.Path+"/revoke" && (o.PagePath == "" || r.URL.Path != o.PagePath) { + http.NotFound(w, r) + return + } + owner, err := o.Owners.ResolveOwner(r) + if err != nil || !identifier(owner.ID) { + httpError(w, ErrDenied) + return + } + if r.URL.Path == o.PagePath { + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + policy := o.Tenants + if policy == nil { + policy, _ = s.options.Policy.(*TenantPolicy) + } + if policy == nil { + httpError(w, ErrUnavailable) + return + } + visible := []TenantConfig{} + for _, c := range policy.configs() { + proposed := Grant{TenantID: c.TenantID, APIAudience: c.APIAudience} + if o.Owners.AuthorizeOwner(r.Context(), owner, "propose", proposed) == nil && s.options.Policy.AllowGrant(r.Context(), proposed) == nil { + visible = append(visible, c) + } + } + if len(visible) == 0 { + httpError(w, ErrDenied) + return + } + body, err := managementHTML(o, visible) + if err != nil { + httpError(w, ErrUnavailable) + return + } + if err = s.record(r.Context(), Audit{Operation: "grant-page", Decision: "allow", Reason: "authorized", OwnerID: owner.ID}); err != nil { + httpError(w, err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(body) + return + } + if r.URL.Path == o.Path && r.Method == http.MethodGet { + if err = o.Owners.AuthorizeOwner(r.Context(), owner, "list", Grant{}); err != nil { + httpError(w, ErrDenied) + return + } + grants, err := manager.ListGrants(r.Context(), owner) + if err != nil { + httpError(w, ErrUnavailable) + return + } + visible := make([]Grant, 0, len(grants)) + for _, g := range grants { + if o.Owners.AuthorizeOwner(r.Context(), owner, "read", g) == nil && s.options.Policy.AllowGrant(r.Context(), g) == nil { + visible = append(visible, g) + } + } + if err = s.record(r.Context(), Audit{Operation: "grant-list", Decision: "allow", Reason: "authorized", OwnerID: owner.ID}); err != nil { + httpError(w, err) + return + } + _ = json.NewEncoder(w).Encode(visible) + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + // Existing verified HUMAN cookie sessions remain usable by the owner + // surface. A pinned Origin/Host and fetch-site check block ambient CSRF; + // the resolver must reject service principals, even with a valid token. + origin, _ := parseHTTPS(o.OwnerOrigin) + if r.Header.Get("Origin") != o.OwnerOrigin || r.Host != origin.Host || len(r.Header.Values("Authorization")) > 1 || (r.Header.Get("Sec-Fetch-Site") != "" && r.Header.Get("Sec-Fetch-Site") != "same-origin") { + httpError(w, ErrDenied) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 20000) + if r.URL.Path == o.Path+"/revoke" { + var input struct { + ID string `json:"id"` + ExpectedVersion int64 `json:"expected_version"` + Confirmed bool `json:"confirmed"` + } + if !readJSON(r, &input) || !input.Confirmed || input.ExpectedVersion <= 0 { + httpError(w, ErrDenied) + return + } + g, err := s.options.Store.GetGrant(r.Context(), input.ID) + if err != nil { + httpError(w, ErrUnavailable) + return + } + if !identifier(input.ID) || g.ID != input.ID || g.Version != input.ExpectedVersion { + httpError(w, ErrConflict) + return + } + if err = o.Owners.AuthorizeOwner(r.Context(), owner, "revoke", g); err != nil { + httpError(w, ErrDenied) + return + } + if err = s.options.Policy.AllowGrant(r.Context(), g); err != nil { + httpError(w, ErrDenied) + return + } + if err = s.record(r.Context(), Audit{Operation: "grant-revoke", Decision: "allow", Reason: "approved", GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: owner.ID, Subject: g.Subject}); err != nil { + httpError(w, err) + return + } + if err = manager.RevokeGrant(r.Context(), g.ID, g.Version, owner.ID, s.now()); err != nil { + if err != ErrConflict { + err = ErrUnavailable + } + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + var input proposal + if !readJSON(r, &input) || !input.Confirmed { + httpError(w, ErrDenied) + return + } + block, rest := pem.Decode([]byte(input.PublicKeyPEM)) + if block == nil || block.Type != "PUBLIC KEY" || len(strings.TrimSpace(string(rest))) != 0 { + httpError(w, ErrDenied) + return + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + httpError(w, ErrDenied) + return + } + public, ok := asPublicKey(key) + if !ok { + httpError(w, ErrDenied) + return + } + id := make([]byte, 16) + if _, err = rand.Read(id); err != nil { + httpError(w, ErrUnavailable) + return + } + g := Grant{ID: hex.EncodeToString(id), Subject: input.Subject, AssertionIssuer: input.AssertionIssuer, KeyID: input.KeyID, PublicKey: public, Version: 1, TenantID: input.TenantID, ApprovedBy: owner.ID, CreatedAt: s.now(), ExpiresAt: input.ExpiresAt.UTC().Truncate(time.Second), APIAudience: input.APIAudience, Scopes: input.Scopes, UpdatePageIDs: input.UpdatePageIDs, Preview: input.Preview} + if !validGrant(g, s.now()) { + httpError(w, ErrDenied) + return + } + if err = s.options.Policy.AllowGrant(r.Context(), g); err != nil { + httpError(w, ErrDenied) + return + } + if err = o.Owners.AuthorizeOwner(r.Context(), owner, "create", g); err != nil { + httpError(w, ErrDenied) + return + } + if err = s.record(r.Context(), Audit{Operation: "grant-create", Decision: "allow", Reason: "approved", GrantID: g.ID, GrantVersion: 1, TenantID: g.TenantID, OwnerID: owner.ID, Subject: g.Subject}); err != nil { + httpError(w, err) + return + } + if err = manager.CreateGrant(r.Context(), g); err != nil { + if err != ErrConflict { + err = ErrUnavailable + } + httpError(w, err) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(g) + }), nil +} + +func privateHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Vary", "Authorization") + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") + w.Header().Set("X-Content-Type-Options", "nosniff") +} +func httpError(w http.ResponseWriter, err error) { + code := http.StatusUnauthorized + if err == ErrUnavailable { + code = http.StatusServiceUnavailable + } + if err == ErrConflict { + code = http.StatusConflict + } + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) +} +func readJSON(r *http.Request, v any) bool { + content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || content != "application/json" { + return false + } + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() + return d.Decode(v) == nil && d.Decode(new(any)) == io.EOF +} +func parseHTTPS(raw string) (*url.URL, error) { return url.Parse(raw) } +func managementPath(p string) bool { + return strings.HasPrefix(p, "/api/") && len(p) > 5 && !strings.HasSuffix(p, "/") && !strings.ContainsAny(p, "%?#\\\t\r\n ") && strings.TrimSpace(p) == p && !strings.Contains(p, "//") && !strings.Contains(p, "/.") +} +func managementPagePath(p string) bool { + return strings.HasPrefix(p, "/admin/") && len(p) > 7 && !strings.ContainsAny(p, "%?#\\\t\r\n ") && !strings.HasSuffix(p, "/") && !strings.Contains(p, "//") && !strings.Contains(p, "/.") +} +func asPublicKey(key any) (*ecdsa.PublicKey, bool) { + k, ok := key.(*ecdsa.PublicKey) + return k, ok && publicKey(k) +} diff --git a/agentidentity/http_test.go b/agentidentity/http_test.go new file mode 100644 index 0000000..3bce97c --- /dev/null +++ b/agentidentity/http_test.go @@ -0,0 +1,379 @@ +package agentidentity + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type ownerFixture struct{ deny bool } + +func (p *ownerFixture) ResolveOwner(r *http.Request) (Owner, error) { + if p.deny || r.Header.Get("Cookie") != "existing-human-session=test-fixture" || r.Header.Get("Authorization") != "" { + return Owner{}, ErrDenied + } + return Owner{ID: "verified-human-owner"}, nil +} +func (p *ownerFixture) AuthorizeOwner(_ context.Context, o Owner, action string, g Grant) error { + if p.deny || o.ID != "verified-human-owner" || (g.TenantID != 0 && g.TenantID != 234) { + return ErrDenied + } + return nil +} +func (s *testStore) CreateGrant(_ context.Context, g Grant) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.grants == nil { + s.grants = map[string]Grant{s.grant.ID: s.grant} + } + if _, ok := s.grants[g.ID]; ok { + return ErrConflict + } + s.grants[g.ID] = g + return nil +} +func (s *testStore) RevokeGrant(_ context.Context, id string, v int64, _ string, _ time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.grants == nil { + s.grants = map[string]Grant{s.grant.ID: s.grant} + } + g, ok := s.grants[id] + if !ok || g.Version != v || g.Revoked { + return ErrConflict + } + g.Revoked = true + g.Version++ + s.grants[id] = g + return nil +} +func (s *testStore) ListGrants(_ context.Context, _ Owner) ([]Grant, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.grants == nil { + return []Grant{s.grant}, nil + } + out := make([]Grant, 0, len(s.grants)) + for _, g := range s.grants { + out = append(out, g) + } + return out, nil +} + +func publicProposal(t *testing.T, f *fixture) map[string]any { + t.Helper() + b, e := x509.MarshalPKIXPublicKey(&f.workload.PublicKey) + if e != nil { + t.Fatal(e) + } + return map[string]any{"subject": "agent-new-preview", "assertion_issuer": "https://workload.example.test", "key_id": "workload-key", "public_key_pem": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: b})), "tenant_id": 234, "expires_at": f.now.Add(24 * time.Hour).Format(time.RFC3339), "api_audience": testAPI, "scopes": []string{PagesRead, HistoryRead}, "confirmed": true} +} +func manageRequest(p string, body map[string]any) *http.Request { + b, _ := json.Marshal(body) + r := httptest.NewRequest("POST", "https://owner.example.test"+p, strings.NewReader(string(b))) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "https://owner.example.test") + r.Header.Set("Cookie", "existing-human-session=test-fixture") + r.Header.Set("Sec-Fetch-Site", "same-origin") + return r +} + +// A read-only wrapper deliberately hides GrantManager even if its underlying +// store can manage grants. Management cannot be mounted against another store. +type validationOnlyStore struct{ Store } + +func TestOwnerManagementRequiresValidationStoreManagement(t *testing.T) { + f := setup(t) + opts := f.s.options + opts.Store = validationOnlyStore{f.store} + s, err := NewService(opts) + if err != nil { + t.Fatal(err) + } + if h, err := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}}); err != ErrUnavailable || h != nil { + t.Fatal("management mounted without the validation authority's manager", err) + } +} + +func TestOwnerRegistrationAndRevocationUseExistingHumanSession(t *testing.T) { + f := setup(t) + h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}}) + if e != nil { + t.Fatal(e) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants", publicProposal(t, f))) + if w.Code != 201 { + t.Fatal("public-key grant registration failed", w.Code, w.Body.String()) + } + var g Grant + if e = json.Unmarshal(w.Body.Bytes(), &g); e != nil || g.ID == "" || g.ApprovedBy != "verified-human-owner" || g.Version != 1 || g.Revoked { + t.Fatal("untrusted owner identity persisted", e) + } + if strings.Contains(w.Body.String(), "PUBLIC KEY") || strings.Contains(w.Body.String(), "test-fixture") { + t.Fatal("credential/public-key payload unexpectedly echoed") + } + r := httptest.NewRequest("GET", "https://owner.example.test/api/v1/admin/auth/agent-grants", nil) + r.Header.Set("Cookie", "existing-human-session=test-fixture") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), g.ID) { + t.Fatal("owner grant inventory absent") + } + f.store.grant = g + out, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, "new-grant-jti", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != nil { + t.Fatal(e) + } + w = httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true})) + if w.Code != 204 { + t.Fatal("owner revoke refused", w.Code) + } + if _, e = f.s.Authorize(context.Background(), out.AccessToken, apiRead()); e != ErrDenied { + t.Fatal("revoked token retained authority", e) + } + w = httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true})) + if w.Code != 409 { + t.Fatal("stale revoke did not conflict", w.Code) + } +} + +func TestOwnerGrantMutationDenialsAndNoPersonalCredentialFallback(t *testing.T) { + for _, kind := range []string{"agent", "no-owner", "wrong-origin", "missing-origin", "wrong-host", "cross-site", "not-confirmed", "role", "live-tenant", "grant-overlong", "privileged-scope", "private-key", "unapproved-page", "audit-down"} { + t.Run(kind, func(t *testing.T) { + f := setup(t) + h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}}) + if e != nil { + t.Fatal(e) + } + body := publicProposal(t, f) + switch kind { + case "not-confirmed": + body["confirmed"] = false + case "role": + body["role"] = "super_admin" + case "live-tenant": + body["tenant_id"] = 999 + case "grant-overlong": + body["expires_at"] = f.now.Add(MaxGrantLifetime + time.Second).Format(time.RFC3339) + case "privileged-scope": + body["scopes"] = []string{"admin:auth.users:update"} + case "private-key": + b, _ := x509.MarshalECPrivateKey(f.workload) + body["public_key_pem"] = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})) + case "unapproved-page": + body["scopes"] = []string{PagesUpdate} + body["update_page_ids"] = []string{"999"} + case "audit-down": + f.audit.fail = true + } + r := manageRequest("/api/v1/admin/auth/agent-grants", body) + switch kind { + case "agent": + r.Header.Del("Cookie") + r.Header.Set("Authorization", "Bearer synthetic-agent-token") + case "no-owner": + r.Header.Del("Cookie") + case "wrong-origin": + r.Header.Set("Origin", "https://evil.example.test") + case "missing-origin": + r.Header.Del("Origin") + case "wrong-host": + r.Host = "alias.example.test" + case "cross-site": + r.Header.Set("Sec-Fetch-Site", "cross-site") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code < 400 || f.store.grants != nil || strings.Contains(w.Body.String(), "PRIVATE KEY") || strings.Contains(w.Body.String(), "test-fixture") { + t.Fatal("unapproved grant/secret accepted", w.Code) + } + }) + } +} + +func TestOwnerPageUsesConfiguredTenantsAndExistingSession(t *testing.T) { + f := setup(t) + p, e := NewTenantPolicy(tenantConfigs()) + if e != nil { + t.Fatal(e) + } + o := f.s.options + o.Policy = p + s, e := NewService(o) + if e != nil { + t.Fatal(e) + } + h, e := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: &ownerFixture{}}) + if e != nil { + t.Fatal(e) + } + r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-access", nil) + r.Header.Set("Cookie", "existing-human-session=test-fixture") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), "data-agent-grant-admin") || !strings.Contains(w.Body.String(), testAPI) || strings.Contains(w.Body.String(), "other-preview") || strings.Contains(w.Body.String(), "localStorage") || strings.Contains(w.Body.String(), "sessionStorage") { + t.Fatal("owner page leaked/unusable config or borrowed credentials") + } + r.Header.Del("Cookie") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 401 { + t.Fatal("anonymous owner page admitted") + } +} + +// The declarations remain fixed while the host's current domain authority can +// change independently. UI declarations must never replace this policy. +type currentPreviewPolicyFixture struct { + Policy + live map[int64]bool + unavailable bool + proposed []Grant +} + +func (p *currentPreviewPolicyFixture) AllowGrant(ctx context.Context, g Grant) error { + p.proposed = append(p.proposed, g) + if p.unavailable { + return ErrUnavailable + } + if p.live[g.TenantID] { + return ErrDenied + } + return p.Policy.AllowGrant(ctx, g) +} + +func (p *currentPreviewPolicyFixture) AllowResource(ctx context.Context, g Grant, r Resource) error { + if err := p.AllowGrant(ctx, g); err != nil { + return err + } + return p.Policy.AllowResource(ctx, g, r) +} + +type tenantOwnerFixture struct { + ownerFixture + allowed map[int64]bool +} + +func (p *tenantOwnerFixture) AuthorizeOwner(_ context.Context, o Owner, _ string, g Grant) error { + if p.deny || o.ID != "verified-human-owner" || (g.TenantID != 0 && !p.allowed[g.TenantID]) { + return ErrDenied + } + return nil +} + +func TestOwnerPageUsesDeclarationsThroughCurrentWrappedPolicy(t *testing.T) { + f := setup(t) + tenants, err := NewTenantPolicy(tenantConfigs()) + if err != nil { + t.Fatal(err) + } + gate := ¤tPreviewPolicyFixture{Policy: tenants, live: map[int64]bool{345: true}} + opts := f.s.options + opts.Policy = gate + s, err := NewService(opts) + if err != nil { + t.Fatal(err) + } + owners := &tenantOwnerFixture{allowed: map[int64]bool{234: true, 345: true}} + management := ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: owners, Tenants: tenants} + h, err := s.ManagementHandler(management) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-access", nil) + r.Header.Set("Cookie", "existing-human-session=test-fixture") + check := func(handler http.Handler, status int, visible, hidden string) { + t.Helper() + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != status || (visible != "" && !strings.Contains(w.Body.String(), visible)) || (hidden != "" && strings.Contains(w.Body.String(), hidden)) { + t.Fatal("declarations bypassed current policy or owner authority", w.Code) + } + } + check(h, http.StatusOK, testAPI, "https://other-preview.example.test/api") + if len(gate.proposed) != 2 { + t.Fatal("both declared proposals must consume the wrapped policy") + } + for _, g := range gate.proposed { + if g.TenantID == 0 || g.APIAudience == "" || g.ID != "" || g.Subject != "" || len(g.Scopes) != 0 || g.Preview != nil { + t.Fatal("owner UI did not use a minimal tenant/audience proposal") + } + } + gate.live[234] = true + delete(gate.live, 345) + check(h, http.StatusOK, "https://other-preview.example.test/api", testAPI) + owners.allowed[345] = false + check(h, http.StatusUnauthorized, "", "https://other-preview.example.test/api") + owners.allowed[345] = true + gate.unavailable = true + check(h, http.StatusUnauthorized, "", testAPI) + management.Tenants = nil + unavailable, err := s.ManagementHandler(management) + if err != nil { + t.Fatal(err) + } + check(unavailable, http.StatusServiceUnavailable, "", testAPI) +} + +func TestOwnerManagementAndAuthorizationRetainWrappedPolicy(t *testing.T) { + f := setup(t) + tenants, err := NewTenantPolicy(tenantConfigs()) + if err != nil { + t.Fatal(err) + } + gate := ¤tPreviewPolicyFixture{Policy: tenants, live: map[int64]bool{}} + opts := f.s.options + opts.Policy = gate + s, err := NewService(opts) + if err != nil { + t.Fatal(err) + } + h, err := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: &tenantOwnerFixture{allowed: map[int64]bool{234: true, 345: true}}, Tenants: tenants}) + if err != nil { + t.Fatal(err) + } + g := f.store.grant + token, err := s.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, "wrapped-policy-before", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if err != nil { + t.Fatal(err) + } + gate.live[234] = true + list := httptest.NewRequest("GET", "https://owner.example.test/api/v1/admin/auth/agent-grants", nil) + list.Header.Set("Cookie", "existing-human-session=test-fixture") + w := httptest.NewRecorder() + h.ServeHTTP(w, list) + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatal("inventory exposed a currently live tenant") + } + for _, r := range []*http.Request{ + manageRequest("/api/v1/admin/auth/agent-grants", publicProposal(t, f)), + manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true}), + } { + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized || f.store.grants != nil { + t.Fatal("management bypassed the current wrapped policy", w.Code) + } + } + if _, err := s.Authorize(context.Background(), token.AccessToken, apiRead()); err != ErrDenied { + t.Fatal("issued token bypassed the current domain gate", err) + } + if _, err := s.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, "wrapped-policy-after", nil), Audience: testAPI, Scopes: []string{PagesRead}}); err != ErrDenied { + t.Fatal("exchange bypassed the current domain gate", err) + } + delete(gate.live, 234) + w = httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true})) + if w.Code != http.StatusNoContent { + t.Fatal("approved owner revoke denied after current gate reopened", w.Code) + } +} diff --git a/agentidentity/policy.go b/agentidentity/policy.go new file mode 100644 index 0000000..b9dc21d --- /dev/null +++ b/agentidentity/policy.go @@ -0,0 +1,155 @@ +package agentidentity + +import ( + "context" + "fmt" + "path" + "slices" + "sort" + "strings" +) + +// TenantConfig is declarative per-preview configuration, independent of keys +// and grants. Adding a tenant requires a configuration entry and owner-approved +// grant, not a source fork, implicit enrollment or shared personal credential. +type TenantConfig struct { + TenantID int64 `json:"tenant_id"` + APIAudience string `json:"api_audience"` + PagesPath string `json:"pages_path"` + HistoryPath string `json:"history_path"` + Scopes []string `json:"scopes"` + UpdatePageIDs []string `json:"update_page_ids"` + Preview *PreviewGrant `json:"preview,omitempty"` +} + +// TenantPolicy is an immutable allowlist of explicit preview tenants. Never +// include a live tenant in this configuration. It matches real host routes, +// field/page ceilings and protected preview origins; it confers no credential. +type TenantPolicy struct{ tenants map[int64]TenantConfig } + +func (p *TenantPolicy) configs() []TenantConfig { + out := make([]TenantConfig, 0, len(p.tenants)) + for _, c := range p.tenants { + c.Scopes = slices.Clone(c.Scopes) + c.UpdatePageIDs = slices.Clone(c.UpdatePageIDs) + if c.Preview != nil { + copy := *c.Preview + copy.Paths = slices.Clone(copy.Paths) + c.Preview = © + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].TenantID < out[j].TenantID }) + return out +} + +func NewTenantPolicy(config []TenantConfig) (*TenantPolicy, error) { + if len(config) == 0 { + return nil, ErrUnavailable + } + p := &TenantPolicy{tenants: map[int64]TenantConfig{}} + for _, c := range config { + if c.TenantID <= 0 || p.tenants[c.TenantID].TenantID != 0 || !httpsURL(c.APIAudience) || !managementPath(c.PagesPath) || path.Clean(c.PagesPath) != c.PagesPath || c.HistoryPath != c.PagesPath+"/history" || !canonicalScopes(c.Scopes) { + return nil, ErrUnavailable + } + if slices.Contains(c.Scopes, PagesUpdate) && len(c.UpdatePageIDs) == 0 { + return nil, ErrUnavailable + } + seen := map[string]bool{} + for _, id := range c.UpdatePageIDs { + if !pageIdentifier(id) || seen[id] { + return nil, ErrUnavailable + } + seen[id] = true + } + if slices.Contains(c.Scopes, PreviewRead) { + if c.Preview == nil || !httpsOrigin(c.Preview.Origin) || !httpsURL(c.Preview.Audience) || c.Preview.Audience == c.APIAudience || len(c.Preview.Paths) == 0 { + return nil, ErrUnavailable + } + seen = map[string]bool{} + for _, route := range c.Preview.Paths { + if !previewTarget(route) || seen[route] { + return nil, ErrUnavailable + } + seen[route] = true + } + } else if c.Preview != nil { + return nil, ErrUnavailable + } + c.Scopes = slices.Clone(c.Scopes) + c.UpdatePageIDs = slices.Clone(c.UpdatePageIDs) + if c.Preview != nil { + copy := *c.Preview + copy.Paths = slices.Clone(copy.Paths) + c.Preview = © + } + p.tenants[c.TenantID] = c + } + return p, nil +} + +func (p *TenantPolicy) AllowGrant(_ context.Context, g Grant) error { + c, ok := p.tenants[g.TenantID] + if !ok || g.APIAudience != c.APIAudience { + return ErrDenied + } + for _, scope := range g.Scopes { + if !slices.Contains(c.Scopes, scope) { + return ErrDenied + } + } + for _, id := range g.UpdatePageIDs { + if !slices.Contains(c.UpdatePageIDs, id) { + return ErrDenied + } + } + if g.Preview != nil { + if c.Preview == nil || g.Preview.Audience != c.Preview.Audience || g.Preview.Origin != c.Preview.Origin { + return ErrDenied + } + for _, route := range g.Preview.Paths { + if !slices.Contains(c.Preview.Paths, route) { + return ErrDenied + } + } + } + return nil +} + +func (p *TenantPolicy) AllowResource(ctx context.Context, g Grant, r Resource) error { + if p.AllowGrant(ctx, g) != nil || r.TenantID != g.TenantID { + return ErrDenied + } + c := p.tenants[g.TenantID] + if r.Scope == PreviewRead { + target, valid := previewResourceTarget(r) + if !valid || c.Preview == nil || r.Origin != c.Preview.Origin || !slices.Contains(c.Preview.Paths, target) { + return ErrDenied + } + return nil + } + // Resource paths must come from canonical host route resolution. Reject + // encoded aliases, trailing slashes, query strings and accidental auth/admin + // routes even if a host misclassifies one as a page-read permission. + if r.Query != "" || strings.ContainsAny(r.Path, "%?#\\") || path.Clean(r.Path) != r.Path { + return ErrDenied + } + switch r.Scope { + case HistoryRead: + if r.Path == c.HistoryPath && r.PageID == "" { + return nil + } + case PagesRead: + if r.Path == c.PagesPath && r.PageID == "" { + return nil + } + if pageIdentifier(r.PageID) && r.Path == fmt.Sprintf("%s/%s", c.PagesPath, r.PageID) { + return nil + } + case PagesUpdate: + if slices.Contains(c.UpdatePageIDs, r.PageID) && r.Path == c.PagesPath+"/"+r.PageID { + return nil + } + } + return ErrDenied +} diff --git a/agentidentity/policy_test.go b/agentidentity/policy_test.go new file mode 100644 index 0000000..4339e99 --- /dev/null +++ b/agentidentity/policy_test.go @@ -0,0 +1,157 @@ +package agentidentity + +import ( + "context" + "testing" +) + +func tenantConfigs() []TenantConfig { + return []TenantConfig{ + {TenantID: 234, APIAudience: testAPI, PagesPath: "/api/v1/admin/tenants/234/pages", HistoryPath: "/api/v1/admin/tenants/234/pages/history", Scopes: []string{PagesRead, HistoryRead, PagesUpdate, PreviewRead}, UpdatePageIDs: []string{"3", "6"}, Preview: &PreviewGrant{Audience: testView, Origin: "https://preview.example.test", Paths: []string{"/", "/rock-metal", "/contact", "/assets/tina.css"}}}, + {TenantID: 345, APIAudience: "https://other-preview.example.test/api", PagesPath: "/api/v1/admin/tenants/345/pages", HistoryPath: "/api/v1/admin/tenants/345/pages/history", Scopes: []string{PagesRead, HistoryRead, PagesUpdate, PreviewRead}, UpdatePageIDs: []string{"17", "18"}, Preview: &PreviewGrant{Audience: "https://other-preview.example.test/view", Origin: "https://other-preview.example.test", Paths: []string{"/", "/about", "/assets/site.css"}}}, + } +} + +func TestDeclarativeTwoTenantAccessAndIsolation(t *testing.T) { + f := setup(t) + config := tenantConfigs() + p, e := NewTenantPolicy(config) + if e != nil { + t.Fatal(e) + } + a := f.store.grant + b := a + b.ID = "grant-2" + b.Subject = "agent-other-preview" + b.TenantID = 345 + b.APIAudience = config[1].APIAudience + b.UpdatePageIDs = config[1].UpdatePageIDs + b.Preview = config[1].Preview + f.store.grants = map[string]Grant{a.ID: a, b.ID: b} + o := f.s.options + o.Policy = p + service, e := NewService(o) + if e != nil { + t.Fatal(e) + } + for _, g := range []Grant{a, b} { + f.store.grant = g // assertion fixture selects the grant's verified subject. + out, e := service.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, g.ID+"-assertion", nil), Audience: g.APIAudience, Scopes: []string{PagesRead, PagesUpdate}}) + if e != nil { + t.Fatal("configured tenant exchange", e) + } + c := config[0] + other := config[1] + if g.TenantID == 345 { + c, other = other, c + } + read := Resource{TenantID: c.TenantID, Audience: c.APIAudience, Scope: PagesRead, Method: "GET", Path: c.PagesPath} + write := Resource{TenantID: c.TenantID, Audience: c.APIAudience, Scope: PagesUpdate, Method: "PUT", Path: c.PagesPath + "/" + c.UpdatePageIDs[0], PageID: c.UpdatePageIDs[0], RootPage: true, Fields: []string{"body_html"}, ExpectedVersion: 1} + for _, r := range []Resource{read, write} { + if _, e := service.Authorize(context.Background(), out.AccessToken, r); e != nil { + t.Fatal("configured resource denied", e) + } + } + wrong := read + wrong.TenantID = other.TenantID + wrong.Path = other.PagesPath + wrong.Audience = other.APIAudience + if _, e := service.Authorize(context.Background(), out.AccessToken, wrong); e == nil { + t.Fatal("cross-tenant token accepted") + } + wrong = write + wrong.PageID = other.UpdatePageIDs[0] + wrong.Path = c.PagesPath + "/" + wrong.PageID + if _, e := service.Authorize(context.Background(), out.AccessToken, wrong); e == nil { + t.Fatal("other tenant page ceiling accepted") + } + view, e := service.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, g.ID+"-view", nil), Audience: g.Preview.Audience, Scopes: []string{PreviewRead}}) + if e != nil { + t.Fatal(e) + } + r := Resource{TenantID: c.TenantID, Audience: c.Preview.Audience, Scope: PreviewRead, Method: "HEAD", Origin: c.Preview.Origin, Path: "/"} + if _, e := service.Authorize(context.Background(), view.AccessToken, r); e != nil { + t.Fatal(e) + } + r.Origin = other.Preview.Origin + if _, e := service.Authorize(context.Background(), view.AccessToken, r); e == nil { + t.Fatal("alternate tenant origin accepted") + } + } + // A third/live tenant omitted from configuration is denied, even if a store + // mistakenly supplies an apparently valid signed/approved grant for it. + live := a + live.TenantID = 999 + if p.AllowGrant(context.Background(), live) != ErrDenied { + t.Fatal("unconfigured tenant admitted") + } + // The copied policy remains immutable when a caller edits source config. + config[0].UpdatePageIDs[0] = "999" + config[0].Preview.Paths[0] = "/admin" + if p.AllowGrant(context.Background(), a) != nil { + t.Fatal("source config alias mutated policy") + } +} + +func TestDeclaredRouteClassificationIsExact(t *testing.T) { + f := setup(t) + p, e := NewTenantPolicy(tenantConfigs()) + if e != nil { + t.Fatal(e) + } + for _, route := range []string{"/api/v1/admin/auth/users", "/api/v1/admin/tenants/234/pages/history", "/api/v1/admin/tenants/345/pages", "/api/v1/admin/tenants/234/pages/", "/api/v1/admin/tenants/234/%70ages", "/api/v1/admin/tenants/234/pages?tenant=345"} { + r := apiRead() + r.Path = route + if p.AllowResource(context.Background(), f.store.grant, r) != ErrDenied { + t.Fatal("misclassified route admitted", route) + } + } + r := apiRead() + r.PageID = "history" + r.Path += "/history" + if p.AllowResource(context.Background(), f.store.grant, r) != ErrDenied { + t.Fatal("page read mislabeled history scope") + } + for _, mutate := range []func([]TenantConfig){func(c []TenantConfig) { c[1].TenantID = c[0].TenantID }, func(c []TenantConfig) { c[0].Preview.Paths = append(c[0].Preview.Paths, "/admin") }, func(c []TenantConfig) { c[0].Scopes = append(c[0].Scopes, "cms.admin") }, func(c []TenantConfig) { c[0].UpdatePageIDs = nil }} { + config := tenantConfigs() + mutate(config) + if _, e := NewTenantPolicy(config); e != ErrUnavailable { + t.Fatal("invalid declaration accepted") + } + } +} + +func TestAssertionOneUseAcrossTenantsAndVersionChanges(t *testing.T) { + f := setup(t) + config := tenantConfigs() + p, e := NewTenantPolicy(config) + if e != nil { + t.Fatal(e) + } + a := f.store.grant + b := a + b.ID = "grant-2" + b.TenantID = 345 + b.APIAudience = config[1].APIAudience + b.UpdatePageIDs = config[1].UpdatePageIDs + b.Preview = config[1].Preview + f.store.grants = map[string]Grant{a.ID: a, b.ID: b} + o := f.s.options + o.Policy = p + s, e := NewService(o) + if e != nil { + t.Fatal(e) + } + raw := f.assertion(t, "global-one-use", nil) + if _, e = s.Exchange(context.Background(), ExchangeRequest{GrantID: a.ID, Assertion: raw, Audience: a.APIAudience, Scopes: []string{PagesRead}}); e != nil { + t.Fatal(e) + } + if _, e = s.Exchange(context.Background(), ExchangeRequest{GrantID: b.ID, Assertion: raw, Audience: b.APIAudience, Scopes: []string{PagesRead}}); e != ErrDenied { + t.Fatal("same assertion reused for another tenant", e) + } + a.Version++ + f.store.grants[a.ID] = a + if _, e = s.Exchange(context.Background(), ExchangeRequest{GrantID: a.ID, Assertion: raw, Audience: a.APIAudience, Scopes: []string{PagesRead}}); e != ErrDenied { + t.Fatal("same assertion reused after version change", e) + } +} diff --git a/agentidentity/postgres/audit.go b/agentidentity/postgres/audit.go new file mode 100644 index 0000000..180f68a --- /dev/null +++ b/agentidentity/postgres/audit.go @@ -0,0 +1,76 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "strconv" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" +) + +var operations = map[string]bool{ + "exchange": true, "access": true, "grant-page": true, "grant-list": true, + "grant-create": true, "grant-revoke": true, "grant-create-committed": true, + "grant-revoke-committed": true, "assertion-consume": true, "token-revoke": true, +} +var reasons = map[string]bool{ + "issued": true, "authorized": true, "approved": true, "denied": true, + "unavailable": true, "committed": true, "consumed": true, "revoked": true, +} + +func validAudit(e agentidentity.Audit) bool { + if e.At.IsZero() || !operations[e.Operation] || !reasons[e.Reason] || (e.Decision != "allow" && e.Decision != "deny") || e.GrantVersion < 0 || e.TenantID < 0 { + return false + } + for _, id := range []string{e.Subject, e.GrantID, e.OwnerID} { + if id != "" && !identifier.MatchString(id) { + return false + } + } + if e.PageID != "" { + id, err := strconv.ParseUint(e.PageID, 10, 64) + if err != nil || id == 0 || strconv.FormatUint(id, 10) != e.PageID { + return false + } + } + if e.JTIHash != "" && !hash.MatchString(e.JTIHash) { + return false + } + if e.Scope != "" && e.Scope != agentidentity.PagesRead && e.Scope != agentidentity.HistoryRead && e.Scope != agentidentity.PagesUpdate && e.Scope != agentidentity.PreviewRead { + return false + } + return true +} + +func (s *Store) appendAudit(ctx context.Context, tx *sql.Tx, e agentidentity.Audit) error { + if !validAudit(e) { + return agentidentity.ErrDenied + } + e.At = e.At.UTC() + payload, err := json.Marshal(e) + if err != nil { + return agentidentity.ErrUnavailable + } + if _, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_management_audit")+" (event) VALUES($1)", payload); err != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +// Record retains fixed-code service/management metadata. Create/Revoke also +// append their distinct committed event in the SAME mutation transaction. +func (s *Store) Record(ctx context.Context, e agentidentity.Audit) error { + if !validAudit(e) { + return agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + if s.appendAudit(ctx, tx, e) != nil || tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} diff --git a/agentidentity/postgres/integration_test.go b/agentidentity/postgres/integration_test.go new file mode 100644 index 0000000..b581772 --- /dev/null +++ b/agentidentity/postgres/integration_test.go @@ -0,0 +1,641 @@ +package postgres + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "database/sql" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" +) + +//go:embed schema.sql +var fixtureSchemaSQL string + +// The integration fixture is explicit and loopback-only. It creates and drops +// only a random schema inside the dedicated synthetic CI/test database. +func fixtureURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.User == nil || u.Path != "/agentidentity_test" || u.RawPath != "" || u.Fragment != "" || u.Opaque != "" { + return nil, agentidentity.ErrDenied + } + ip := net.ParseIP(u.Hostname()) + port, err := strconv.ParseUint(u.Port(), 10, 16) + if ip == nil || !ip.IsLoopback() || err != nil || port == 0 || !identifier.MatchString(u.User.Username()) { + return nil, agentidentity.ErrDenied + } + if password, ok := u.User.Password(); !ok || password == "" { + return nil, agentidentity.ErrDenied + } + q, err := url.ParseQuery(u.RawQuery) + if err != nil || len(q) != 1 || len(q["sslmode"]) != 1 || q.Get("sslmode") != "disable" { + return nil, agentidentity.ErrDenied + } + return u, nil +} + +func TestIntegrationURLRequiresExplicitSyntheticLoopbackDatabase(t *testing.T) { + good := "postgres://agentidentity_test:isolated-test-only@127.0.0.1:5432/agentidentity_test?sslmode=disable" + if _, err := fixtureURL(good); err != nil { + t.Fatal("isolated CI URI refused") + } + for _, bad := range []string{ + "", "host=127.0.0.1 dbname=agentidentity_test", + "postgres://fixture:synthetic@database.example.test:5432/agentidentity_test?sslmode=disable", + "postgres://fixture:synthetic@127.0.0.1:5432/production?sslmode=disable", + "postgres://fixture@127.0.0.1:5432/agentidentity_test?sslmode=disable", + "postgres://fixture:synthetic@127.0.0.1/agentidentity_test?sslmode=disable", + good + "&service=personal", good + "&passfile=/personal/pgpass", good + "&host=remote.example.test", + good + "&sslmode=disable", good + "#fragment", + } { + if _, err := fixtureURL(bad); err != agentidentity.ErrDenied { + t.Fatal("unsafe integration URI accepted") + } + } +} + +type pgFixture struct { + db *sql.DB + store *Store + config *pgx.ConnConfig +} + +func newPGFixture(t *testing.T) *pgFixture { + t.Helper() + raw := os.Getenv("AGENTIDENTITY_TEST_DATABASE_URL") + if raw == "" { + t.Skip("explicit AGENTIDENTITY_TEST_DATABASE_URL not provided; no database/profile action") + } + u, err := fixtureURL(raw) + if err != nil { + t.Fatal("integration database must be explicit synthetic loopback URI") + } + // Prevent inherited personal service, passfile, SSL or runtime profiles. + // These tests do not run in parallel because Setenv is process-scoped. + for _, name := range []string{"PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD", "PGPASSFILE", "PGAPPNAME", "PGCONNECT_TIMEOUT", "PGSSLMODE", "PGSSLKEY", "PGSSLCERT", "PGSSLSNI", "PGSSLROOTCERT", "PGSSLPASSWORD", "PGSSLNEGOTIATION", "PGTARGETSESSIONATTRS", "PGSERVICE", "PGSERVICEFILE", "PGTZ", "PGOPTIONS", "PGMINPROTOCOLVERSION", "PGMAXPROTOCOLVERSION"} { + t.Setenv(name, "") + } + q := u.Query() + q.Set("passfile", filepath.Join(t.TempDir(), "nonexistent-synthetic-passfile")) + q.Set("connect_timeout", "5") + u.RawQuery = q.Encode() + config, err := pgx.ParseConfig(u.String()) + if err != nil { + t.Fatal("synthetic connection configuration failed") + } + db := stdlib.OpenDB(*config) + db.SetMaxOpenConns(8) + t.Cleanup(func() { db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if db.PingContext(ctx) != nil { + t.Fatal("explicit synthetic PostgreSQL fixture unavailable") + } + var nonce [12]byte + if _, err := rand.Read(nonce[:]); err != nil { + t.Fatal("schema nonce failed") + } + schema := "agent_fixture_" + hex.EncodeToString(nonce[:]) + if _, err := db.ExecContext(ctx, `CREATE SCHEMA "`+schema+`"`); err != nil { + t.Fatal("owned synthetic schema create failed") + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if _, err := db.ExecContext(ctx, `DROP SCHEMA "`+schema+`" CASCADE`); err != nil { + t.Error("owned synthetic schema cleanup failed") + } + }) + tx, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatal("fixture transaction failed") + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `SET LOCAL search_path TO "`+schema+`"`); err != nil { + t.Fatal("owned schema search path failed") + } + if _, err := tx.ExecContext(ctx, fixtureSchemaSQL); err != nil { + t.Fatal("explicit fixture-only SQL adoption failed") + } + if tx.Commit() != nil { + t.Fatal("fixture migration commit failed") + } + s, err := New(db, schema) + if err != nil { + t.Fatal("fixture store failed") + } + return &pgFixture{db: db, store: s, config: config} +} + +func (f *pgFixture) reconnect(t *testing.T) *Store { + t.Helper() + db := stdlib.OpenDB(*f.config) + db.SetMaxOpenConns(8) + t.Cleanup(func() { db.Close() }) + s, err := New(db, f.store.schema) + if err != nil { + t.Fatal("reconnected fixture store failed") + } + return s +} + +func (f *pgFixture) count(t *testing.T, table string) int { + t.Helper() + var n int + if err := f.db.QueryRow("SELECT count(*) FROM " + f.store.table(table)).Scan(&n); err != nil { + t.Fatal("fixture row count failed") + } + return n +} + +func assertionUse(g agentidentity.Grant, label string) agentidentity.AssertionUse { + now := time.Now().UTC().Truncate(time.Microsecond) + return agentidentity.AssertionUse{GrantID: g.ID, GrantVersion: g.Version, JTIHash: fixtureHash(label), Now: now, ExpiresAt: now.Add(45 * time.Second)} +} + +func TestPostgresGrantReplayRevokeSurviveNewConnectionPool(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + g := fixtureGrant(t, "fixture-one", 501) + if err := f.store.CreateGrant(ctx, g); err != nil { + t.Fatal("valid grant create failed") + } + if f.store.CreateGrant(ctx, g) != agentidentity.ErrConflict { + t.Fatal("duplicate create was not a CAS conflict") + } + loaded, err := f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || loaded.PublicKey.X.Cmp(g.PublicKey.X) != 0 || loaded.PublicKey.Y.Cmp(g.PublicKey.Y) != 0 || loaded.Preview.Origin != g.Preview.Origin || loaded.TenantID != g.TenantID || loaded.ApprovedBy != g.ApprovedBy || loaded.Version != 1 { + t.Fatal("grant/public key/content did not persist across connection pools") + } + use := assertionUse(g, "reconnect-assertion") + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || !ok { + t.Fatal("first assertion consume failed") + } + if ok, err := f.reconnect(t).ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("replay survived reconnect") + } + if f.store.RevokeGrant(ctx, g.ID, 2, "current-owner", time.Now()) != agentidentity.ErrConflict { + t.Fatal("stale expected version did not conflict") + } + if f.store.RevokeGrant(ctx, g.ID, 1, "current-owner", time.Now()) != nil { + t.Fatal("matching revoke failed") + } + if f.store.RevokeGrant(ctx, g.ID, 1, "current-owner", time.Now()) != agentidentity.ErrConflict { + t.Fatal("repeat revoke did not conflict") + } + loaded, err = f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || !loaded.Revoked || loaded.Version != 2 { + t.Fatal("revoke/version bump did not persist") + } + for _, version := range []int64{1, 2} { + use = assertionUse(g, "revoked-assertion") + use.GrantVersion = version + if ok, err := f.reconnect(t).ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("revoked grant assertion was admitted") + } + } + now := time.Now().UTC() + if err := f.store.RevokeToken(ctx, fixtureHash("token-one"), "current-owner", now, now.Add(time.Minute)); err != nil { + t.Fatal("token revoke failed") + } + before := f.count(t, "agent_management_audit") + if err := f.store.RevokeToken(ctx, fixtureHash("token-one"), "current-owner", now, now.Add(time.Minute)); err != nil || f.count(t, "agent_management_audit") != before { + t.Fatal("idempotent token revoke duplicated committed outbox event") + } + if revoked, err := f.reconnect(t).IsTokenRevoked(ctx, fixtureHash("token-one")); err != nil || !revoked { + t.Fatal("token revocation did not persist") + } + if revoked, err := f.store.IsTokenRevoked(ctx, fixtureHash("different-token")); err != nil || revoked { + t.Fatal("unrelated token hash was revoked") + } + if f.count(t, "agent_management_audit") != 4 { + t.Fatal("committed create/consume/revoke/token events were not retained exactly once") + } +} + +func TestPostgresGlobalReplayConcurrentTenantsAndVersions(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + one, two := fixtureGrant(t, "fixture-one", 501), fixtureGrant(t, "fixture-two", 502) + two.PublicKey = one.PublicKey // One workload identity, independent tenant grants. + for _, g := range []agentidentity.Grant{one, two} { + if err := f.store.CreateGrant(ctx, g); err != nil { + t.Fatal("tenant grant create failed") + } + if ok, err := f.store.ConsumeAssertion(ctx, assertionUse(g, g.ID+"-positive")); err != nil || !ok { + t.Fatal("independent tenant positive consume failed") + } + } + start := make(chan struct{}) + type outcome struct { + ok bool + err error + } + results := make(chan outcome, 24) + var workers sync.WaitGroup + for i := 0; i < 24; i++ { + g := one + if i%2 != 0 { + g = two + } + use := assertionUse(g, "same-issuer-subject-kid-jti") + workers.Add(1) + go func() { + defer workers.Done() + <-start + ok, err := f.store.ConsumeAssertion(ctx, use) + results <- outcome{ok, err} + }() + } + close(start) + workers.Wait() + close(results) + winners := 0 + for result := range results { + if result.err != nil { + t.Fatal("concurrent fixture consume failed") + } + if result.ok { + winners++ + } + } + if winners != 1 || f.count(t, "agent_assertion_uses") != 3 { + t.Fatal("global replay identity had multiple winners across grants") + } + // Fixture-only permission-edit version bump proves uniqueness is not + // partitioned by grant version. No production edit API is introduced. + if _, err := f.db.Exec("UPDATE "+f.store.table("agent_grants")+" SET version=2 WHERE id=$1", one.ID); err != nil { + t.Fatal("fixture version bump failed") + } + one.Version = 2 + if ok, err := f.reconnect(t).ConsumeAssertion(ctx, assertionUse(one, "same-issuer-subject-kid-jti")); err != nil || ok { + t.Fatal("version bump reopened globally consumed assertion") + } + if ok, err := f.store.ConsumeAssertion(ctx, assertionUse(one, "new-version-positive")); err != nil || !ok { + t.Fatal("fresh assertion at current version failed") + } + stale := assertionUse(one, "stale-version") + stale.GrantVersion = 1 + if ok, err := f.store.ConsumeAssertion(ctx, stale); err != nil || ok { + t.Fatal("stale grant version admitted") + } +} + +func TestPostgresExpiryUsesDatabaseClockAndRevokeSerialization(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + g := fixtureGrant(t, "fixture-expiry", 501) + g.CreatedAt = time.Now().UTC().Add(-2 * time.Hour) + g.ExpiresAt = time.Now().UTC().Add(time.Hour) + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("expiry fixture create failed") + } + use := assertionUse(g, "expired-assertion") + use.Now, use.ExpiresAt = time.Now().UTC().Add(-time.Minute), time.Now().UTC().Add(-30*time.Second) + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("lagging caller clock admitted expired assertion") + } + if _, err := f.db.Exec("UPDATE "+f.store.table("agent_grants")+" SET expires_at=$2 WHERE id=$1", g.ID, time.Now().UTC().Add(-time.Second)); err != nil { + t.Fatal("owned expiry fixture update failed") + } + use = assertionUse(g, "expired-grant") + use.Now = time.Now().UTC().Add(-30 * time.Second) + use.ExpiresAt = use.Now.Add(time.Minute) + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("lagging caller clock admitted expired grant") + } + expired := fixtureGrant(t, "expired-create", 502) + expired.CreatedAt, expired.ExpiresAt = g.CreatedAt, time.Now().UTC().Add(-time.Second) + if f.store.CreateGrant(ctx, expired) != agentidentity.ErrDenied { + t.Fatal("expired create accepted") + } + live := fixtureGrant(t, "fixture-lock", 502) + if f.store.CreateGrant(ctx, live) != nil { + t.Fatal("lock fixture create failed") + } + tx, err := f.db.BeginTx(ctx, nil) + if err != nil { + t.Fatal("lock transaction failed") + } + defer tx.Rollback() + if _, err := tx.Exec("UPDATE "+f.store.table("agent_grants")+" SET revoked=true,version=2,revoked_by='fixture-owner',revoked_at=clock_timestamp() WHERE id=$1", live.ID); err != nil { + t.Fatal("owned concurrent revoke update failed") + } + result := make(chan bool, 1) + errResult := make(chan error, 1) + go func() { + ok, err := f.store.ConsumeAssertion(ctx, assertionUse(live, "locked-revoke")) + result <- ok + errResult <- err + }() + if tx.Commit() != nil { + t.Fatal("owned concurrent revoke commit failed") + } + if <-result || <-errResult != nil { + t.Fatal("consumer did not observe current revoked row after lock") + } +} + +func TestPostgresAuditFailureRollsBackEveryMutation(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + g := fixtureGrant(t, "fixture-rollback", 501) + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("rollback fixture create failed") + } + before := f.count(t, "agent_management_audit") + function := f.store.table("fixture_reject_audit") + if _, err := f.db.Exec("CREATE FUNCTION " + function + "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic-secret-token-PEM-DSN'; END; $$"); err != nil { + t.Fatal("owned audit failure fixture failed") + } + if _, err := f.db.Exec("CREATE TRIGGER fixture_audit_failure BEFORE INSERT ON " + f.store.table("agent_management_audit") + " FOR EACH ROW EXECUTE FUNCTION " + function + "()"); err != nil { + t.Fatal("owned audit failure trigger failed") + } + newGrant := fixtureGrant(t, "fixture-rollback-new", 502) + use := assertionUse(g, "rollback-assertion") + now := time.Now().UTC() + if f.store.CreateGrant(ctx, newGrant) != agentidentity.ErrUnavailable || f.store.RevokeGrant(ctx, g.ID, 1, "current-owner", now) != agentidentity.ErrUnavailable { + t.Fatal("audit failure did not fail grant mutations closed") + } + if ok, err := f.store.ConsumeAssertion(ctx, use); err != agentidentity.ErrUnavailable || ok { + t.Fatal("audit failure did not roll back assertion consume") + } + if f.store.RevokeToken(ctx, fixtureHash("rollback-token"), "current-owner", now, now.Add(time.Minute)) != agentidentity.ErrUnavailable { + t.Fatal("audit failure did not fail token revoke closed") + } + if f.store.Record(ctx, agentidentity.Audit{At: now, Operation: "access", Decision: "deny", Reason: "denied"}) != agentidentity.ErrUnavailable { + t.Fatal("audit raw database failure escaped redaction") + } + loaded, err := f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || loaded.Revoked || loaded.Version != 1 || f.count(t, "agent_grants") != 1 || f.count(t, "agent_assertion_uses") != 0 || f.count(t, "agent_token_revocations") != 0 || f.count(t, "agent_management_audit") != before { + t.Fatal("failed transaction left partial durable changes") + } + if _, err := f.db.Exec("DROP TRIGGER fixture_audit_failure ON " + f.store.table("agent_management_audit")); err != nil { + t.Fatal("owned audit failure trigger removal failed") + } + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || !ok { + t.Fatal("rolled-back assertion was incorrectly consumed") + } +} + +func TestPostgresSQLConstraintsAndRetainedRows(t *testing.T) { + f := newPGFixture(t) + g := fixtureGrant(t, "fixture-constraints", 501) + ctx := context.Background() + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("constraint fixture create failed") + } + for _, clause := range []string{"tenant_id=0", "version=0", "expires_at=created_at+interval '721 hours'", "expires_at=created_at", "created_at='infinity'::timestamptz", "revoked=true"} { + if _, err := f.db.Exec("UPDATE "+f.store.table("agent_grants")+" SET "+clause+" WHERE id=$1", g.ID); err == nil { + t.Fatal("database structural constraint did not refuse invalid row") + } + } + if ok, err := f.store.ConsumeAssertion(ctx, assertionUse(g, "retained")); err != nil || !ok { + t.Fatal("retained fixture consume failed") + } + now := time.Now().UTC() + if f.store.RevokeToken(ctx, fixtureHash("retained-token"), "owner", now, now.Add(time.Minute)) != nil { + t.Fatal("retained fixture token revoke failed") + } + for _, table := range []string{"agent_assertion_uses", "agent_token_revocations", "agent_management_audit"} { + before := f.count(t, table) + for _, operation := range []string{"DELETE FROM ", "TRUNCATE "} { + if _, err := f.db.Exec(operation + f.store.table(table)); err == nil { + t.Fatal("retained record rewrite accepted") + } + } + if f.count(t, table) != before { + t.Fatal("retained records changed") + } + } + if _, err := f.db.Exec("UPDATE " + f.store.table("agent_management_audit") + " SET event='{}'::jsonb"); err == nil { + t.Fatal("audit update accepted") + } +} + +type ownerContextKey struct{} +type additionalOwnerPolicy struct{ tenants map[string]int64 } + +func (p additionalOwnerPolicy) ResolveOwner(r *http.Request) (agentidentity.Owner, error) { + owner, ok := r.Context().Value(ownerContextKey{}).(agentidentity.Owner) + if !ok { + return owner, agentidentity.ErrDenied + } + return owner, nil +} +func (p additionalOwnerPolicy) AuthorizeOwner(_ context.Context, owner agentidentity.Owner, operation string, g agentidentity.Grant) error { + tenant, ok := p.tenants[owner.ID] + if !ok { + return agentidentity.ErrDenied + } + if operation == "list" || tenant == g.TenantID { + return nil + } + return agentidentity.ErrDenied +} + +func TestPostgresOwnerListAndResourcePolicyApplyCurrentTenantAuthority(t *testing.T) { + ctx := context.Background() + one, two := fixtureGrant(t, "fixture-owner-one", 501), fixtureGrant(t, "fixture-owner-two", 502) + two.PublicKey = one.PublicKey + two.UpdatePageIDs = []string{"20"} + two.Preview = &agentidentity.PreviewGrant{Audience: "https://second.example.test/audience", Origin: "https://second.example.test", Paths: []string{"/", "/music"}} + configs := fixtureTenantConfigs(one, two) + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("two-tenant policy failed before database skip") + } + f := newPGFixture(t) + for _, g := range []agentidentity.Grant{one, two} { + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("owner list fixture create failed") + } + } + for i, g := range []agentidentity.Grant{one, two} { + loaded, err := f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || policy.AllowGrant(ctx, loaded) != nil { + t.Fatal("tenant policy positive failed") + } + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesUpdate, PageID: g.UpdatePageIDs[0], Path: configs[i].PagesPath + "/" + g.UpdatePageIDs[0]} + if policy.AllowResource(ctx, loaded, resource) != nil { + t.Fatal("tenant resource positive failed") + } + resource.TenantID = 503 + if policy.AllowResource(ctx, loaded, resource) != agentidentity.ErrDenied { + t.Fatal("cross-tenant resource admitted") + } + resource.TenantID = g.TenantID + resource.PageID, resource.Path = "999", configs[i].PagesPath+"/999" + if policy.AllowResource(ctx, loaded, resource) != agentidentity.ErrDenied { + t.Fatal("other tenant/page ceiling admitted") + } + } + owners := additionalOwnerPolicy{tenants: map[string]int64{"current-one": 501, "current-two": 502}} + service, err := agentidentity.NewService(agentidentity.Options{IssuerURL: "https://issuer.example.test", TokenEndpoint: "https://issuer.example.test/agent/token", IssuerKeys: map[string]*ecdsa.PublicKey{"issuer-kid": one.PublicKey}, Store: f.reconnect(t), Audit: f.store, Policy: policy, Issuer: agentidentity.IssuerFunc(func(context.Context, agentidentity.IssueRequest) (string, error) { return "", agentidentity.ErrDenied })}) + if err != nil { + t.Fatal("durable management fixture service failed") + } + handler, err := service.ManagementHandler(agentidentity.ManagementOptions{Path: fixtureManagementPath, OwnerOrigin: "https://owner.example.test", Owners: owners}) + if err != nil { + t.Fatal("management fixture handler failed") + } + for _, owner := range []string{"current-one", "current-two"} { + r := httptest.NewRequest("GET", "https://owner.example.test"+fixtureManagementPath, nil) + r = r.WithContext(context.WithValue(r.Context(), ownerContextKey{}, agentidentity.Owner{ID: owner})) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + var grants []agentidentity.Grant + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &grants) != nil || len(grants) != 1 || grants[0].TenantID != owners.tenants[owner] { + t.Fatal("current owner tenant filtering failed") + } + } + // Request metadata cannot substitute for the verified owner context. + r := httptest.NewRequest("GET", "https://owner.example.test"+fixtureManagementPath, nil) + r.Header.Set("X-Owner-ID", "current-one") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatal("header owner spoof accepted") + } + delete(owners.tenants, "current-one") + r = r.WithContext(context.WithValue(r.Context(), ownerContextKey{}, agentidentity.Owner{ID: "current-one"})) + w = httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatal("removed current owner retained list access") + } + if _, err := f.store.GetGrant(ctx, "unconfigured-new-site"); !errors.Is(err, agentidentity.ErrDenied) { + t.Fatal("site/config creation inferred an agent grant") + } +} + +// This signer is a synthetic fixture, not a Workflow managed issuer adapter. +// It proves persistence/core interoperability without external key custody. +type fixtureAccessClaims struct { + jwt.RegisteredClaims + TokenUse string `json:"token_use"` + GrantID string `json:"grant_id"` + GrantVersion int64 `json:"grant_version"` + TenantID int64 `json:"tenant_id"` + Scope string `json:"scope"` +} + +func TestPostgresServiceExchangeAuthorizeAcrossTenantsAndRevocations(t *testing.T) { + ctx := context.Background() + issuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("synthetic issuer key fixture failed") + } + workload, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("synthetic workload key fixture failed") + } + one, two := fixtureGrant(t, "service-one", 501), fixtureGrant(t, "service-two", 502) + one.PublicKey, two.PublicKey = &workload.PublicKey, &workload.PublicKey + configs := fixtureTenantConfigs(one, two) + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("service tenant fixture policy failed before database skip") + } + f := newPGFixture(t) + for _, g := range []agentidentity.Grant{one, two} { + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("service grant fixture create failed") + } + } + const endpoint = "https://issuer.example.test/agent/token" + issuer := agentidentity.IssuerFunc(func(_ context.Context, r agentidentity.IssueRequest) (string, error) { + claims := fixtureAccessClaims{ + RegisteredClaims: jwt.RegisteredClaims{Issuer: "https://issuer.example.test", Subject: r.Subject, Audience: jwt.ClaimStrings{r.Audience}, IssuedAt: jwt.NewNumericDate(r.IssuedAt), ExpiresAt: jwt.NewNumericDate(r.ExpiresAt), ID: "issued-" + r.GrantID}, + TokenUse: "agent_access", GrantID: r.GrantID, GrantVersion: r.GrantVersion, TenantID: r.TenantID, Scope: strings.Join(r.Scopes, " "), + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = "synthetic-issuer-key" + raw, err := token.SignedString(issuerKey) + if err != nil { + return "", agentidentity.ErrUnavailable + } + return raw, nil + }) + newService := func() *agentidentity.Service { + s, err := agentidentity.NewService(agentidentity.Options{IssuerURL: "https://issuer.example.test", TokenEndpoint: endpoint, IssuerKeys: map[string]*ecdsa.PublicKey{"synthetic-issuer-key": &issuerKey.PublicKey}, Store: f.reconnect(t), Audit: f.store, Policy: policy, Issuer: issuer}) + if err != nil { + t.Fatal("durable service fixture construction failed") + } + return s + } + assertion := func(id string) string { + now := time.Now().UTC().Truncate(time.Second) + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.RegisteredClaims{Issuer: one.AssertionIssuer, Subject: one.Subject, Audience: jwt.ClaimStrings{endpoint}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(45 * time.Second)), ID: id}) + token.Header["kid"] = one.KeyID + raw, err := token.SignedString(workload) + if err != nil { + t.Fatal("synthetic assertion fixture signing failed") + } + return raw + } + service := newService() + sharedAssertion := assertion("global-service-assertion") + first, err := service.Exchange(ctx, agentidentity.ExchangeRequest{GrantID: one.ID, Assertion: sharedAssertion, Audience: one.APIAudience, Scopes: []string{agentidentity.PagesRead}}) + if err != nil { + t.Fatal("durable first tenant service exchange failed") + } + if _, err := newService().Exchange(ctx, agentidentity.ExchangeRequest{GrantID: two.ID, Assertion: sharedAssertion, Audience: two.APIAudience, Scopes: []string{agentidentity.PagesRead}}); err != agentidentity.ErrDenied { + t.Fatal("core global bound assertion replay across tenant grants admitted") + } + second, err := newService().Exchange(ctx, agentidentity.ExchangeRequest{GrantID: two.ID, Assertion: assertion("second-service-assertion"), Audience: two.APIAudience, Scopes: []string{agentidentity.PagesRead}}) + if err != nil { + t.Fatal("durable second tenant service exchange failed") + } + var firstPrincipal agentidentity.Principal + for i, g := range []agentidentity.Grant{one, two} { + raw := first.AccessToken + if i == 1 { + raw = second.AccessToken + } + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[i].PagesPath} + principal, err := newService().Authorize(ctx, raw, resource) + if err != nil || principal.TenantID != g.TenantID { + t.Fatal("durable current tenant service authorization failed") + } + if i == 0 { + firstPrincipal = principal + } + resource.TenantID = 503 + if _, err := service.Authorize(ctx, raw, resource); err != agentidentity.ErrDenied { + t.Fatal("core cross-tenant token admitted") + } + } + now := time.Now().UTC() + if f.store.RevokeToken(ctx, firstPrincipal.JTIHash, "current-owner", now, now.Add(time.Minute)) != nil { + t.Fatal("service token revocation fixture failed") + } + if _, err := newService().Authorize(ctx, first.AccessToken, agentidentity.Resource{TenantID: one.TenantID, Audience: one.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[0].PagesPath}); err != agentidentity.ErrDenied { + t.Fatal("durable revoked token admitted after new service/pool") + } + if f.store.RevokeGrant(ctx, two.ID, 1, "current-owner", time.Now()) != nil { + t.Fatal("service grant revoke fixture failed") + } + if _, err := newService().Authorize(ctx, second.AccessToken, agentidentity.Resource{TenantID: two.TenantID, Audience: two.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[1].PagesPath}); err != agentidentity.ErrDenied { + t.Fatal("durable revoked grant token admitted after new service/pool") + } +} diff --git a/agentidentity/postgres/schema.sql b/agentidentity/postgres/schema.sql new file mode 100644 index 0000000..a6daa30 --- /dev/null +++ b/agentidentity/postgres/schema.sql @@ -0,0 +1,67 @@ +-- Explicit, unapplied host prerequisite. Run in one caller-owned transaction +-- with a reviewed schema search_path. Constructors never execute this file. +-- These rows contain service grants/public keys, not human credentials. +CREATE TABLE agent_grants ( + id TEXT PRIMARY KEY CHECK (id ~ '^[A-Za-z0-9_.:-]{1,128}$'), + subject TEXT NOT NULL CHECK (subject ~ '^[A-Za-z0-9_.:-]{1,128}$'), + assertion_issuer TEXT NOT NULL CHECK (length(assertion_issuer) BETWEEN 1 AND 2048), + key_id TEXT NOT NULL CHECK (key_id ~ '^[A-Za-z0-9_.:-]{1,128}$'), + public_key_der BYTEA NOT NULL CHECK (octet_length(public_key_der) BETWEEN 1 AND 512), + version BIGINT NOT NULL CHECK (version > 0), + tenant_id BIGINT NOT NULL CHECK (tenant_id > 0), + approved_by TEXT NOT NULL CHECK (approved_by ~ '^[A-Za-z0-9_.:-]{1,128}$'), + created_at TIMESTAMPTZ NOT NULL CHECK (isfinite(created_at)), + expires_at TIMESTAMPTZ NOT NULL CHECK (isfinite(expires_at)), + revoked BOOLEAN NOT NULL DEFAULT false, + api_audience TEXT NOT NULL CHECK (length(api_audience) BETWEEN 1 AND 2048), + scopes JSONB NOT NULL CHECK (jsonb_typeof(scopes) = 'array' AND jsonb_array_length(scopes) BETWEEN 1 AND 4), + update_page_ids JSONB NOT NULL CHECK (jsonb_typeof(update_page_ids) = 'array' AND jsonb_array_length(update_page_ids) <= 128), + preview JSONB CHECK (preview IS NULL OR jsonb_typeof(preview) = 'object'), + revoked_by TEXT CHECK (revoked_by ~ '^[A-Za-z0-9_.:-]{1,128}$'), + revoked_at TIMESTAMPTZ CHECK (isfinite(revoked_at)), + CHECK (expires_at > created_at AND expires_at - created_at <= interval '720 hours'), + CHECK ((NOT revoked AND revoked_by IS NULL AND revoked_at IS NULL) OR + (revoked AND version > 1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL AND revoked_at >= created_at)) +); +CREATE INDEX agent_grants_tenant_id ON agent_grants (tenant_id, id); +CREATE TABLE agent_assertion_uses ( + jti_hash TEXT PRIMARY KEY CHECK (jti_hash ~ '^[0-9a-f]{64}$'), + grant_id TEXT NOT NULL REFERENCES agent_grants(id), + grant_version BIGINT NOT NULL CHECK (grant_version > 0), + consumed_at TIMESTAMPTZ NOT NULL CHECK (isfinite(consumed_at)), + expires_at TIMESTAMPTZ NOT NULL CHECK (isfinite(expires_at)), + CHECK (expires_at > consumed_at AND expires_at - consumed_at <= interval '1 minute') +); +-- The primary key is GLOBAL in this adapter's shared schema. Grant/version are +-- metadata only; partitioning uniqueness by either would allow replay. +CREATE TABLE agent_token_revocations ( + jti_hash TEXT PRIMARY KEY CHECK (jti_hash ~ '^[0-9a-f]{64}$'), + revoked_by TEXT NOT NULL CHECK (revoked_by ~ '^[A-Za-z0-9_.:-]{1,128}$'), + revoked_at TIMESTAMPTZ NOT NULL CHECK (isfinite(revoked_at)), + expires_at TIMESTAMPTZ NOT NULL CHECK (isfinite(expires_at)), + CHECK (expires_at > revoked_at AND expires_at - revoked_at <= interval '15 minutes') +); +-- Immutable audit rows form an outbox. Delivery cursors belong to the host; +-- successful delivery never deletes or edits these retained records. +CREATE TABLE agent_management_audit ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event JSONB NOT NULL CHECK (jsonb_typeof(event) = 'object'), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); +CREATE FUNCTION agent_refuse_retained_rewrite() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'retained service identity record is append-only'; +END; +$$; +CREATE TRIGGER agent_audit_append_only BEFORE UPDATE OR DELETE ON agent_management_audit + FOR EACH ROW EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_audit_no_truncate BEFORE TRUNCATE ON agent_management_audit + FOR EACH STATEMENT EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_assertion_append_only BEFORE UPDATE OR DELETE ON agent_assertion_uses + FOR EACH ROW EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_assertion_no_truncate BEFORE TRUNCATE ON agent_assertion_uses + FOR EACH STATEMENT EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_revocation_append_only BEFORE UPDATE OR DELETE ON agent_token_revocations + FOR EACH ROW EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_revocation_no_truncate BEFORE TRUNCATE ON agent_token_revocations + FOR EACH STATEMENT EXECUTE FUNCTION agent_refuse_retained_rewrite(); diff --git a/agentidentity/postgres/store.go b/agentidentity/postgres/store.go new file mode 100644 index 0000000..e5be286 --- /dev/null +++ b/agentidentity/postgres/store.go @@ -0,0 +1,304 @@ +// Package postgres persists agentidentity service grants in a host-owned SQL +// schema. It never provisions a database, schema, issuer key or human identity. +package postgres + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "database/sql" + "encoding/json" + "errors" + "math" + "regexp" + "time" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" +) + +var namespace = regexp.MustCompile("^[a-z_][a-z0-9_]{0,62}$") +var identifier = regexp.MustCompile("^[A-Za-z0-9_.:-]{1,128}$") +var hash = regexp.MustCompile("^[0-9a-f]{64}$") + +const maxList = 4096 + +// Store uses one shared schema for ALL tenant grants and replay identities. +// The caller owns connection security/pool lifecycle, migration and current +// human/tenant authorization. Schema is copied/validated; no search_path is used. +type Store struct { + db *sql.DB + schema string +} + +// New validates configuration only. It performs no I/O, Ping or DDL. +func New(db *sql.DB, schema string) (*Store, error) { + if db == nil || !namespace.MatchString(schema) { + return nil, agentidentity.ErrUnavailable + } + return &Store{db: db, schema: schema}, nil +} + +func (s *Store) table(name string) string { return `"` + s.schema + `"."` + name + `"` } + +const grantColumns = "id,subject,assertion_issuer,key_id,public_key_der,version,tenant_id,approved_by,created_at,expires_at,revoked,api_audience,scopes,update_page_ids,preview" + +type scanner interface{ Scan(...any) error } + +func scanGrant(row scanner) (agentidentity.Grant, error) { + var g agentidentity.Grant + var key, scopes, pages, preview []byte + dest := []any{&g.ID, &g.Subject, &g.AssertionIssuer, &g.KeyID, &key, &g.Version, &g.TenantID, &g.ApprovedBy, &g.CreatedAt, &g.ExpiresAt, &g.Revoked, &g.APIAudience, &scopes, &pages, &preview} + if err := row.Scan(dest...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return g, agentidentity.ErrDenied + } + return g, agentidentity.ErrUnavailable + } + if len(key) > 512 || len(scopes) > 4096 || len(pages) > 4096 || len(preview) > 16384 { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + pk, err := x509.ParsePKIXPublicKey(key) + if err != nil { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + public, ok := pk.(*ecdsa.PublicKey) + if !ok || public.Curve != elliptic.P256() { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + g.PublicKey = public + if json.Unmarshal(scopes, &g.Scopes) != nil || json.Unmarshal(pages, &g.UpdatePageIDs) != nil { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + if len(preview) != 0 { + if json.Unmarshal(preview, &g.Preview) != nil || g.Preview == nil { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + } + g.CreatedAt, g.ExpiresAt = g.CreatedAt.UTC(), g.ExpiresAt.UTC() + if agentidentity.ValidateGrantForStorage(g) != nil || len(g.UpdatePageIDs) > 128 || (g.Revoked && g.Version < 2) { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + return g, nil +} + +func (s *Store) GetGrant(ctx context.Context, id string) (agentidentity.Grant, error) { + if !identifier.MatchString(id) { + return agentidentity.Grant{}, agentidentity.ErrDenied + } + return scanGrant(s.db.QueryRowContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" WHERE id=$1", id)) +} + +// ListGrants is internal persistence, not an owner-authorized endpoint. The +// reusable management handler filters each row using CURRENT OwnerPolicy. +// Original ApprovedBy must not be used to infer today's tenant authority. +func (s *Store) ListGrants(ctx context.Context, owner agentidentity.Owner) ([]agentidentity.Grant, error) { + if !identifier.MatchString(owner.ID) { + return nil, agentidentity.ErrDenied + } + rows, err := s.db.QueryContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" ORDER BY id LIMIT $1", maxList+1) + if err != nil { + return nil, agentidentity.ErrUnavailable + } + defer rows.Close() + out := []agentidentity.Grant{} + for rows.Next() { + if len(out) == maxList { + return nil, agentidentity.ErrUnavailable + } + g, err := scanGrant(rows) + if err != nil { + return nil, agentidentity.ErrUnavailable + } + out = append(out, g) + } + if rows.Err() != nil { + return nil, agentidentity.ErrUnavailable + } + return out, nil +} + +func sqlConflict(err error) bool { + var e interface{ SQLState() string } + return errors.As(err, &e) && e.SQLState() == "23505" +} + +func (s *Store) CreateGrant(ctx context.Context, g agentidentity.Grant) error { + if g.Version != 1 || g.Revoked || agentidentity.ValidateGrantForStorage(g) != nil || len(g.UpdatePageIDs) > 128 { + return agentidentity.ErrDenied + } + key, err := x509.MarshalPKIXPublicKey(g.PublicKey) + if err != nil { + return agentidentity.ErrDenied + } + scopes, err := json.Marshal(g.Scopes) + if err != nil { + return agentidentity.ErrDenied + } + pages := g.UpdatePageIDs + if pages == nil { + pages = []string{} + } + pageJSON, err := json.Marshal(pages) + if err != nil { + return agentidentity.ErrDenied + } + var preview any + if g.Preview != nil { + b, err := json.Marshal(g.Preview) + if err != nil || len(b) > 16384 { + return agentidentity.ErrDenied + } + preview = b + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_grants")+" ("+grantColumns+") SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15 WHERE $9::timestamptz<=clock_timestamp() AND $10::timestamptz>clock_timestamp()", g.ID, g.Subject, g.AssertionIssuer, g.KeyID, key, g.Version, g.TenantID, g.ApprovedBy, g.CreatedAt.UTC(), g.ExpiresAt.UTC(), false, g.APIAudience, scopes, pageJSON, preview) + if err != nil { + if sqlConflict(err) { + return agentidentity.ErrConflict + } + return agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return agentidentity.ErrUnavailable + } + if n != 1 { + return agentidentity.ErrDenied + } + event := agentidentity.Audit{At: g.CreatedAt.UTC(), Operation: "grant-create-committed", Decision: "allow", Reason: "committed", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy} + if s.appendAudit(ctx, tx, event) != nil || tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +func (s *Store) RevokeGrant(ctx context.Context, id string, expectedVersion int64, actor string, at time.Time) error { + if !identifier.MatchString(id) || !identifier.MatchString(actor) || expectedVersion <= 0 || expectedVersion == math.MaxInt64 || at.IsZero() { + return agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + g, err := scanGrant(tx.QueryRowContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" WHERE id=$1 FOR UPDATE", id)) + if errors.Is(err, agentidentity.ErrDenied) { + return agentidentity.ErrConflict + } + if err != nil { + return agentidentity.ErrUnavailable + } + if g.Revoked || g.Version != expectedVersion { + return agentidentity.ErrConflict + } + if at.Before(g.CreatedAt) { + return agentidentity.ErrDenied + } + result, err := tx.ExecContext(ctx, "UPDATE "+s.table("agent_grants")+" SET revoked=true,version=version+1,revoked_by=$3,revoked_at=$4 WHERE id=$1 AND version=$2 AND NOT revoked", id, expectedVersion, actor, at.UTC()) + if err != nil { + return agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return agentidentity.ErrUnavailable + } + if n != 1 { + return agentidentity.ErrConflict + } + event := agentidentity.Audit{At: at.UTC(), Operation: "grant-revoke-committed", Decision: "allow", Reason: "committed", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version + 1, TenantID: g.TenantID, OwnerID: actor} + if s.appendAudit(ctx, tx, event) != nil || tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +// ConsumeAssertion serializes with grant revoke and atomically rechecks the +// current row. The DB clock also refuses expired state if a caller clock lags. +// One bound JTIHash is consumed globally, never by grant/version partition. +func (s *Store) ConsumeAssertion(ctx context.Context, use agentidentity.AssertionUse) (bool, error) { + if !identifier.MatchString(use.GrantID) || use.GrantVersion <= 0 || !hash.MatchString(use.JTIHash) || use.Now.IsZero() || !use.ExpiresAt.After(use.Now) || use.ExpiresAt.Sub(use.Now) > agentidentity.MaxAssertionLifetime { + return false, agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, agentidentity.ErrUnavailable + } + defer tx.Rollback() + g, err := scanGrant(tx.QueryRowContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" WHERE id=$1 FOR UPDATE", use.GrantID)) + if errors.Is(err, agentidentity.ErrDenied) { + return false, nil + } + if err != nil { + return false, agentidentity.ErrUnavailable + } + if g.Revoked || g.Version != use.GrantVersion || g.CreatedAt.After(use.Now) || !g.ExpiresAt.After(use.Now) { + return false, nil + } + result, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_assertion_uses")+" (jti_hash,grant_id,grant_version,consumed_at,expires_at) SELECT $1,id,version,$4,$5 FROM "+s.table("agent_grants")+" WHERE id=$2 AND version=$3 AND NOT revoked AND created_at<=clock_timestamp() AND expires_at>clock_timestamp() AND $5::timestamptz>clock_timestamp() ON CONFLICT (jti_hash) DO NOTHING", use.JTIHash, g.ID, g.Version, use.Now.UTC(), use.ExpiresAt.UTC()) + if err != nil { + return false, agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return false, agentidentity.ErrUnavailable + } + if n != 1 { + return false, nil + } + event := agentidentity.Audit{At: use.Now.UTC(), Operation: "assertion-consume", Decision: "allow", Reason: "consumed", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy, JTIHash: use.JTIHash} + if s.appendAudit(ctx, tx, event) != nil || tx.Commit() != nil { + return false, agentidentity.ErrUnavailable + } + return true, nil +} + +func (s *Store) IsTokenRevoked(ctx context.Context, jtiHash string) (bool, error) { + if !hash.MatchString(jtiHash) { + return false, agentidentity.ErrDenied + } + var revoked bool + if err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM "+s.table("agent_token_revocations")+" WHERE jti_hash=$1)", jtiHash).Scan(&revoked); err != nil { + return false, agentidentity.ErrUnavailable + } + return revoked, nil +} + +// RevokeToken records only an already-approved token identity hash. A caller +// must authorize the actor/target separately; this typed adapter grants no role. +func (s *Store) RevokeToken(ctx context.Context, jtiHash, actor string, at, expiresAt time.Time) error { + if !hash.MatchString(jtiHash) || !identifier.MatchString(actor) || at.IsZero() || !expiresAt.After(at) || expiresAt.Sub(at) > agentidentity.MaxTokenLifetime { + return agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_token_revocations")+" (jti_hash,revoked_by,revoked_at,expires_at) VALUES($1,$2,$3,$4) ON CONFLICT (jti_hash) DO NOTHING", jtiHash, actor, at.UTC(), expiresAt.UTC()) + if err != nil { + return agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return agentidentity.ErrUnavailable + } + if n == 1 { + event := agentidentity.Audit{At: at.UTC(), Operation: "token-revoke", Decision: "allow", Reason: "revoked", OwnerID: actor, JTIHash: jtiHash} + if s.appendAudit(ctx, tx, event) != nil { + return agentidentity.ErrUnavailable + } + } + if tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +var _ agentidentity.Store = (*Store)(nil) +var _ agentidentity.GrantManager = (*Store)(nil) +var _ agentidentity.Auditor = (*Store)(nil) diff --git a/agentidentity/postgres/store_test.go b/agentidentity/postgres/store_test.go new file mode 100644 index 0000000..31065ad --- /dev/null +++ b/agentidentity/postgres/store_test.go @@ -0,0 +1,252 @@ +package postgres + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "database/sql" + "database/sql/driver" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" +) + +const syntheticLeak = "postgres://synthetic-secret@example.invalid/db Bearer synthetic-token -----BEGIN PRIVATE KEY-----" + +const fixtureManagementPath = "/api/v1/admin/agent-grants" + +var driverOpens atomic.Int64 +var driverCalls atomic.Int64 + +type failingDriver struct{} +type failingConn struct{} + +func (failingDriver) Open(string) (driver.Conn, error) { + driverOpens.Add(1) + return failingConn{}, nil +} +func (failingConn) Prepare(string) (driver.Stmt, error) { + driverCalls.Add(1) + return nil, errors.New(syntheticLeak) +} +func (failingConn) Close() error { return nil } +func (failingConn) Begin() (driver.Tx, error) { + driverCalls.Add(1) + return nil, errors.New(syntheticLeak) +} +func (failingConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + driverCalls.Add(1) + return nil, errors.New(syntheticLeak) +} + +func init() { sql.Register("agentidentity-postgres-redaction-fixture", failingDriver{}) } + +func fixtureGrant(t *testing.T, id string, tenant int64) agentidentity.Grant { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("synthetic public-key fixture failed") + } + now := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Second) + return agentidentity.Grant{ + ID: id, Subject: "synthetic-agent", AssertionIssuer: "https://agent.example.test", + KeyID: "synthetic-kid", PublicKey: &key.PublicKey, Version: 1, TenantID: tenant, + ApprovedBy: "synthetic-owner", CreatedAt: now, ExpiresAt: now.Add(time.Hour), + APIAudience: "https://cms.example.test", + Scopes: []string{agentidentity.PagesRead, agentidentity.HistoryRead, agentidentity.PagesUpdate, agentidentity.PreviewRead}, + UpdatePageIDs: []string{"10"}, + Preview: &agentidentity.PreviewGrant{Audience: "https://preview.example.test/audience", Origin: "https://preview.example.test", Paths: []string{"/", "/teaching"}}, + } +} + +func fixtureHash(label string) string { + h := sha256.Sum256([]byte("synthetic-fixture:" + label)) + return hex.EncodeToString(h[:]) +} + +// Source tests and PG tests use the same declarative routes. No database or +// environment-dependent skip precedes this configuration's source check. +func fixtureTenantConfigs(grants ...agentidentity.Grant) []agentidentity.TenantConfig { + configs := make([]agentidentity.TenantConfig, 0, len(grants)) + for _, g := range grants { + pages := fmt.Sprintf("/api/v1/admin/tenants/%d/pages", g.TenantID) + configs = append(configs, agentidentity.TenantConfig{TenantID: g.TenantID, APIAudience: g.APIAudience, PagesPath: pages, HistoryPath: pages + "/history", Scopes: g.Scopes, UpdatePageIDs: g.UpdatePageIDs, Preview: g.Preview}) + } + return configs +} + +type fixtureNoOpOwnerPolicy struct{} + +func (fixtureNoOpOwnerPolicy) ResolveOwner(*http.Request) (agentidentity.Owner, error) { + return agentidentity.Owner{}, agentidentity.ErrDenied +} +func (fixtureNoOpOwnerPolicy) AuthorizeOwner(context.Context, agentidentity.Owner, string, agentidentity.Grant) error { + return agentidentity.ErrDenied +} + +func TestPGFixtureTenantAndManagementConfigurationWithoutDatabase(t *testing.T) { + one, two := fixtureGrant(t, "source-one", 501), fixtureGrant(t, "source-two", 502) + two.UpdatePageIDs = []string{"20"} + two.Preview = &agentidentity.PreviewGrant{Audience: "https://second.example.test/audience", Origin: "https://second.example.test", Paths: []string{"/", "/music"}} + configs := fixtureTenantConfigs(one, two) + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("shared PG fixture tenant configuration invalid before database skip") + } + for i, g := range []agentidentity.Grant{one, two} { + if policy.AllowGrant(context.Background(), g) != nil { + t.Fatal("shared PG fixture grant ceiling invalid") + } + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[i].PagesPath} + if policy.AllowResource(context.Background(), g, resource) != nil { + t.Fatal("shared PG fixture route invalid") + } + } + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + store, _ := New(db, "agent_fixture") + before := driverOpens.Load() + service, err := agentidentity.NewService(agentidentity.Options{IssuerURL: "https://issuer.example.test", TokenEndpoint: "https://issuer.example.test/agent/token", IssuerKeys: map[string]*ecdsa.PublicKey{"issuer-kid": one.PublicKey}, Store: store, Audit: store, Policy: policy, Issuer: agentidentity.IssuerFunc(func(context.Context, agentidentity.IssueRequest) (string, error) { return "", agentidentity.ErrDenied })}) + if err != nil { + t.Fatal("shared PG fixture service configuration invalid") + } + if _, err := service.ManagementHandler(agentidentity.ManagementOptions{Path: fixtureManagementPath, OwnerOrigin: "https://owner.example.test", Owners: fixtureNoOpOwnerPolicy{}}); err != nil { + t.Fatal("shared PG fixture management path invalid") + } + if driverOpens.Load() != before { + t.Fatal("fixture configuration source check performed database I/O") + } +} + +func TestConstructorHasNoIOAndValidatesNamespace(t *testing.T) { + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + before := driverOpens.Load() + for _, schema := range []string{"agent_identity", "a", strings.Repeat("a", 63)} { + store, err := New(db, schema) + if err != nil || store.schema != schema { + t.Fatal("valid namespace refused") + } + } + for _, schema := range []string{"", "public;drop schema public", "a.b", `a"b`, "MixedCase", "1first", strings.Repeat("a", 64), "a\n"} { + if _, err := New(db, schema); err != agentidentity.ErrUnavailable { + t.Fatal("unsafe namespace accepted") + } + } + if _, err := New(nil, "agent_identity"); err != agentidentity.ErrUnavailable { + t.Fatal("nil database accepted") + } + if driverOpens.Load() != before { + t.Fatal("constructor performed database I/O") + } +} + +func TestDatabaseFailuresReturnOnlyFixedPublicErrors(t *testing.T) { + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + s, err := New(db, "agent_fixture") + if err != nil { + t.Fatal("constructor failed") + } + ctx := context.Background() + g := fixtureGrant(t, "fixture-grant", 501) + now := time.Now().UTC() + checks := []struct { + name string + run func() error + }{ + {"get", func() error { _, err := s.GetGrant(ctx, g.ID); return err }}, + {"list", func() error { _, err := s.ListGrants(ctx, agentidentity.Owner{ID: "owner"}); return err }}, + {"create", func() error { return s.CreateGrant(ctx, g) }}, + {"revoke", func() error { return s.RevokeGrant(ctx, g.ID, 1, "owner", now) }}, + {"consume", func() error { + _, err := s.ConsumeAssertion(ctx, agentidentity.AssertionUse{GrantID: g.ID, GrantVersion: 1, JTIHash: fixtureHash("assertion"), Now: now, ExpiresAt: now.Add(30 * time.Second)}) + return err + }}, + {"read-revocation", func() error { _, err := s.IsTokenRevoked(ctx, fixtureHash("token")); return err }}, + {"revoke-token", func() error { return s.RevokeToken(ctx, fixtureHash("token"), "owner", now, now.Add(time.Minute)) }}, + {"audit", func() error { + return s.Record(ctx, agentidentity.Audit{At: now, Operation: "access", Decision: "deny", Reason: "denied"}) + }}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); err != agentidentity.ErrUnavailable || strings.Contains(err.Error(), syntheticLeak) { + t.Fatal("database error was not redacted to fixed unavailable") + } + }) + } +} + +func TestInvalidInputsAndUnboundedAuditNeverReachDatabase(t *testing.T) { + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + s, _ := New(db, "agent_fixture") + ctx := context.Background() + before := driverCalls.Load() + g := fixtureGrant(t, "fixture-grant", 501) + g.Version = 2 + if s.CreateGrant(ctx, g) != agentidentity.ErrDenied { + t.Fatal("create did not enforce initial version") + } + g.Version, g.Revoked = 1, true + if s.CreateGrant(ctx, g) != agentidentity.ErrDenied { + t.Fatal("revoked create accepted") + } + if _, err := s.GetGrant(ctx, "bad/id"); err != agentidentity.ErrDenied { + t.Fatal("invalid grant ID accepted") + } + if _, err := s.ListGrants(ctx, agentidentity.Owner{}); err != agentidentity.ErrDenied { + t.Fatal("missing owner accepted") + } + if s.RevokeGrant(ctx, "fixture-grant", 0, "owner", time.Now()) != agentidentity.ErrDenied { + t.Fatal("missing expected version accepted") + } + if _, err := s.IsTokenRevoked(ctx, syntheticLeak); err != agentidentity.ErrDenied { + t.Fatal("raw token accepted as a hash") + } + if s.RevokeToken(ctx, fixtureHash("token"), "owner", time.Now(), time.Now().Add(time.Hour)) != agentidentity.ErrDenied { + t.Fatal("overlong token lifetime accepted") + } + base := agentidentity.Audit{At: time.Now(), Operation: "access", Decision: "deny", Reason: "denied"} + for _, mutate := range []func(*agentidentity.Audit){ + func(a *agentidentity.Audit) { a.Reason = syntheticLeak }, + func(a *agentidentity.Audit) { a.Operation = syntheticLeak }, + func(a *agentidentity.Audit) { a.Subject = syntheticLeak }, + func(a *agentidentity.Audit) { a.OwnerID = "owner@example.test" }, + func(a *agentidentity.Audit) { a.Scope = "admin:*" }, + func(a *agentidentity.Audit) { a.JTIHash = syntheticLeak }, + func(a *agentidentity.Audit) { a.PageID = "18446744073709551616" }, + func(a *agentidentity.Audit) { a.PageID = "01" }, + } { + a := base + mutate(&a) + if s.Record(ctx, a) != agentidentity.ErrDenied { + t.Fatal("unbounded audit metadata accepted") + } + } + if driverCalls.Load() != before { + t.Fatal("invalid input reached database") + } +} diff --git a/agentidentity/preview_target.go b/agentidentity/preview_target.go new file mode 100644 index 0000000..c13b8b6 --- /dev/null +++ b/agentidentity/preview_target.go @@ -0,0 +1,53 @@ +package agentidentity + +import "strings" + +// Query targets remain finite exact entries in both the current grant and the +// host's tenant declaration. This syntax bound prevents encoded/duplicate +// aliases; it does not grant every query on an otherwise allowed path. +func previewTarget(target string) bool { + pathname, query, queried := strings.Cut(target, "?") + return len(target) <= 2048 && previewPath(pathname) && (!queried || canonicalPreviewQuery(query)) +} + +func previewResourceTarget(r Resource) (string, bool) { + if !previewPath(r.Path) { + return "", false + } + target := r.Path + if r.Query != "" { + target += "?" + r.Query + } + return target, previewTarget(target) +} + +func canonicalPreviewQuery(query string) bool { + if query == "" || len(query) > 512 { + return false + } + pairs := strings.Split(query, "&") + if len(pairs) > 8 { + return false + } + previous := "" + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + if !found || !previewQueryAtom(key, 64) || !previewQueryAtom(value, 128) || key <= previous { + return false + } + previous = key + } + return true +} + +func previewQueryAtom(value string, max int) bool { + if len(value) == 0 || len(value) > max { + return false + } + for _, c := range value { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strings.ContainsRune("-._~", c)) { + return false + } + } + return true +} diff --git a/agentidentity/preview_target_test.go b/agentidentity/preview_target_test.go new file mode 100644 index 0000000..c390a8e --- /dev/null +++ b/agentidentity/preview_target_test.go @@ -0,0 +1,106 @@ +package agentidentity + +import ( + "context" + "strings" + "testing" +) + +func TestPreviewQueryRequiresExactCurrentGrantAndTenantTarget(t *testing.T) { + f := setup(t) + configs := tenantConfigs() + target := "/contact?interest=voice-study" + configs[0].Preview.Paths = append(configs[0].Preview.Paths, target) + policy, err := NewTenantPolicy(configs) + if err != nil { + t.Fatal(err) + } + f.store.grant.Preview = configs[0].Preview + options := f.s.options + options.Policy = policy + f.s, err = NewService(options) + if err != nil { + t.Fatal(err) + } + out := f.exchange(t, []string{PreviewRead}, testView) + resource := Resource{TenantID: 234, Audience: testView, Scope: PreviewRead, Method: "GET", Origin: configs[0].Preview.Origin, Path: "/contact", Query: "interest=voice-study"} + for _, method := range []string{"GET", "HEAD"} { + resource.Method = method + if _, err := f.s.Authorize(context.Background(), out.AccessToken, resource); err != nil { + t.Fatalf("exact query %s refused: %v", method, err) + } + } + for _, mutate := range []func(*Resource){ + func(r *Resource) { r.Query = "interest=booking" }, + func(r *Resource) { r.Query = "interest=voice-study&other=1" }, + func(r *Resource) { r.Query = "interest=voice-study&interest=voice-study" }, + func(r *Resource) { r.Query = "interest=%76oice-study" }, + func(r *Resource) { r.Query = "interest=voice-study&" }, + func(r *Resource) { r.Query = "interest=voice-study;other=1" }, + func(r *Resource) { r.Path = "/contact?interest=voice-study"; r.Query = "" }, + func(r *Resource) { r.Path = "/admin" }, + func(r *Resource) { r.Method = "POST" }, + func(r *Resource) { r.Origin = configs[1].Preview.Origin }, + func(r *Resource) { r.Audience = configs[1].Preview.Audience }, + func(r *Resource) { r.TenantID = 345 }, + } { + changed := resource + mutate(&changed) + if _, err := f.s.Authorize(context.Background(), out.AccessToken, changed); err != ErrDenied { + t.Fatalf("query ceiling widened: %v", err) + } + } + // A current grant narrowing removes authority from the already-issued token. + f.store.grant.Preview = &PreviewGrant{Audience: testView, Origin: configs[0].Preview.Origin, Paths: []string{"/contact"}} + if _, err := f.s.Authorize(context.Background(), out.AccessToken, resource); err != ErrDenied { + t.Fatal("bare path inherited query authority") + } + if policy.AllowResource(context.Background(), f.store.grant, resource) != nil { + // The host declaration still lists the target; the service's current-grant + // check above is what denies the narrowed persisted grant. + t.Fatal("unchanged host declaration unexpectedly lost its target") + } + other := f.store.grant + other.TenantID, other.APIAudience, other.Preview = 345, configs[1].APIAudience, configs[1].Preview + other.UpdatePageIDs = configs[1].UpdatePageIDs + otherResource := resource + otherResource.TenantID, otherResource.Audience, otherResource.Origin = 345, configs[1].Preview.Audience, configs[1].Preview.Origin + if policy.AllowResource(context.Background(), other, otherResource) != ErrDenied { + t.Fatal("second tenant inherited the first tenant's query declaration") + } + apiFixture := setup(t) + apiToken := apiFixture.exchange(t, []string{PagesRead}, testAPI) + api := apiRead() + if _, err := apiFixture.s.Authorize(context.Background(), apiToken.AccessToken, api); err != nil { + t.Fatal("unchanged API read refused") + } + api.Query = "interest=voice-study" + if _, err := apiFixture.s.Authorize(context.Background(), apiToken.AccessToken, api); err != ErrDenied { + t.Fatal("preview query accepted on API plane") + } +} + +func TestPreviewQueryInvalidDeclarationsAndStoredGrantsRefuse(t *testing.T) { + for _, target := range []string{ + "/contact?", "/contact?interest=", "/contact?interest", "/contact?interest=voice-study&", "/contact?interest=voice-study&interest=voice-study", + "/contact?interest=%76oice-study", "/contact?%69nterest=voice-study", "/contact?interest=voice+study", "/contact?interest=voice-study#x", + "/contact?interest=voice-study;other=1", "/contact?z=1&a=2", "/contact?interest=" + strings.Repeat("a", 129), "/admin?interest=voice-study", + } { + t.Run(target, func(t *testing.T) { + configs := tenantConfigs() + configs[0].Preview.Paths = append(configs[0].Preview.Paths, target) + if _, err := NewTenantPolicy(configs); err != ErrUnavailable { + t.Fatal("invalid query declaration accepted") + } + f := setup(t) + f.store.grant.Preview.Paths = append(f.store.grant.Preview.Paths, target) + if ValidateGrantForStorage(f.store.grant) != ErrDenied { + t.Fatal("invalid persisted query grant accepted") + } + _, err := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: f.store.grant.ID, Assertion: f.assertion(t, "invalid-query-grant", nil), Audience: testView, Scopes: []string{PreviewRead}}) + if err != ErrDenied { + t.Fatal("invalid current query grant issued a token") + } + }) + } +} diff --git a/agentidentity/service.go b/agentidentity/service.go new file mode 100644 index 0000000..cd0e4c5 --- /dev/null +++ b/agentidentity/service.go @@ -0,0 +1,323 @@ +package agentidentity + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "encoding/hex" + "math/big" + "net/url" + "path" + "slices" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Service struct{ options Options } + +type accessClaims struct { + jwt.RegisteredClaims + TokenUse string `json:"token_use"` + GrantID string `json:"grant_id"` + GrantVersion int64 `json:"grant_version"` + TenantID int64 `json:"tenant_id"` + Scope string `json:"scope"` +} + +func NewService(o Options) (*Service, error) { + if !httpsURL(o.IssuerURL) || !httpsURL(o.TokenEndpoint) || o.Store == nil || o.Issuer == nil || o.Policy == nil || o.Audit == nil || len(o.IssuerKeys) == 0 { + return nil, ErrUnavailable + } + keys := make(map[string]*ecdsa.PublicKey, len(o.IssuerKeys)) + for id, k := range o.IssuerKeys { + if !identifier(id) || !publicKey(k) { + return nil, ErrUnavailable + } + // Copy the curve coordinates so external configuration mutation cannot + // silently replace a trusted key after construction. + keys[id] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).Set(k.X), Y: new(big.Int).Set(k.Y)} + } + o.IssuerKeys = keys + if o.Clock == nil { + o.Clock = time.Now + } + return &Service{options: o}, nil +} + +func (s *Service) now() time.Time { return s.options.Clock().UTC().Truncate(time.Second) } + +func (s *Service) current(ctx context.Context, id string) (Grant, error) { + if !identifier(id) { + return Grant{}, ErrDenied + } + g, err := s.options.Store.GetGrant(ctx, id) + if err != nil { + return Grant{}, ErrUnavailable + } + if g.ID != id || !validGrant(g, s.now()) { + return Grant{}, ErrDenied + } + if err = s.options.Policy.AllowGrant(ctx, g); err != nil { + return Grant{}, ErrDenied + } + return g, nil +} + +func (s *Service) Exchange(ctx context.Context, in ExchangeRequest) (TokenResponse, error) { + g, err := s.current(ctx, in.GrantID) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", Grant{}, err) + } + if len(in.Assertion) > 16384 || in.Assertion == "" || !audienceScopes(g, in.Audience, in.Scopes) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + now := s.now() + c := new(jwt.RegisteredClaims) + t, err := jwt.ParseWithClaims(in.Assertion, c, func(t *jwt.Token) (any, error) { + if t.Header["kid"] != g.KeyID { + return nil, ErrDenied + } + return g.PublicKey, nil + }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(g.AssertionIssuer), jwt.WithSubject(g.Subject), jwt.WithAudience(s.options.TokenEndpoint), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(func() time.Time { return now })) + if err != nil || t == nil || !t.Valid || !exactAudience(c.Audience, s.options.TokenEndpoint) || c.IssuedAt == nil || c.ExpiresAt == nil || !identifier(c.ID) || !shortTimes(c.IssuedAt, c.ExpiresAt, now, MaxAssertionLifetime) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + used, err := s.options.Store.ConsumeAssertion(ctx, AssertionUse{GrantID: g.ID, GrantVersion: g.Version, JTIHash: digest(c.Issuer + "\x00" + c.Subject + "\x00" + g.KeyID + "\x00" + c.ID), ExpiresAt: c.ExpiresAt.Time, Now: now}) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + if !used { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + exp := now.Add(MaxTokenLifetime) + if g.ExpiresAt.Before(exp) { + exp = g.ExpiresAt.UTC().Truncate(time.Second) + } + if !exp.After(now) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + request := IssueRequest{Subject: g.Subject, Audience: in.Audience, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, Scopes: slices.Clone(in.Scopes), IssuedAt: now, ExpiresAt: exp} + raw, err := s.options.Issuer.IssueAgentToken(ctx, request) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + // Verify the issuer adapter's complete output before disclosing it. This + // catches accidental human/legacy issuer wiring, audience/scope widening, + // overlong expiry and grant mutation during issuance. + issued, err := s.parseAccess(raw, in.Audience, now) + if err != nil || issued.Subject != g.Subject || issued.GrantID != g.ID || issued.GrantVersion != g.Version || issued.TenantID != g.TenantID || issued.IssuedAt.Time.Unix() != now.Unix() || issued.ExpiresAt.Time.Unix() != exp.Unix() || !sameScopes(strings.Fields(issued.Scope), in.Scopes) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + current, err := s.current(ctx, g.ID) + if err != nil || current.Version != g.Version || !audienceScopes(current, in.Audience, in.Scopes) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + revoked, err := s.options.Store.IsTokenRevoked(ctx, digest(issued.ID)) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + if revoked { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + if err = s.record(ctx, Audit{Operation: "exchange", Decision: "allow", Reason: "issued", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy, JTIHash: digest(issued.ID)}); err != nil { + return TokenResponse{}, err + } + return TokenResponse{AccessToken: raw, TokenType: "Bearer", ExpiresIn: int64(exp.Sub(now).Seconds()), Scope: strings.Join(in.Scopes, " ")}, nil +} + +func (s *Service) Authorize(ctx context.Context, raw string, r Resource) (Principal, error) { + now := s.now() + c, err := s.parseAccess(raw, r.Audience, now) + if err != nil { + return Principal{}, s.reject(ctx, "access", Grant{}, ErrDenied) + } + g, err := s.current(ctx, c.GrantID) + if err != nil { + return Principal{}, s.reject(ctx, "access", Grant{}, err) + } + if c.Subject != g.Subject || c.GrantVersion != g.Version || c.TenantID != g.TenantID || r.TenantID != g.TenantID || c.ExpiresAt.Time.After(g.ExpiresAt) || !audienceScopes(g, r.Audience, strings.Fields(c.Scope)) || !slices.Contains(strings.Fields(c.Scope), r.Scope) || !resourceAllowed(g, r) { + return Principal{}, s.reject(ctx, "access", g, ErrDenied) + } + revoked, err := s.options.Store.IsTokenRevoked(ctx, digest(c.ID)) + if err != nil { + return Principal{}, s.reject(ctx, "access", g, ErrUnavailable) + } + if revoked { + return Principal{}, s.reject(ctx, "access", g, ErrDenied) + } + if err = s.options.Policy.AllowResource(ctx, g, r); err != nil { + return Principal{}, s.reject(ctx, "access", g, ErrDenied) + } + if err = s.record(ctx, Audit{Operation: "access", Decision: "allow", Reason: "authorized", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy, PageID: r.PageID, Scope: r.Scope, JTIHash: digest(c.ID)}); err != nil { + return Principal{}, err + } + return Principal{Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, Scopes: strings.Fields(c.Scope), JTIHash: digest(c.ID)}, nil +} + +func (s *Service) parseAccess(raw, audience string, now time.Time) (*accessClaims, error) { + if len(raw) == 0 || len(raw) > 16384 || !httpsURL(audience) { + return nil, ErrDenied + } + c := new(accessClaims) + t, err := jwt.ParseWithClaims(raw, c, func(t *jwt.Token) (any, error) { + kid, ok := t.Header["kid"].(string) + if !ok || s.options.IssuerKeys[kid] == nil { + return nil, ErrDenied + } + return s.options.IssuerKeys[kid], nil + }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(s.options.IssuerURL), jwt.WithAudience(audience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(func() time.Time { return now })) + if err != nil || t == nil || !t.Valid || c.TokenUse != "agent_access" || !identifier(c.Subject) || !identifier(c.ID) || !identifier(c.GrantID) || c.GrantVersion <= 0 || c.TenantID <= 0 || !exactAudience(c.Audience, audience) || !shortTimes(c.IssuedAt, c.ExpiresAt, now, MaxTokenLifetime) || !canonicalScopes(strings.Fields(c.Scope)) || strings.Join(strings.Fields(c.Scope), " ") != c.Scope { + return nil, ErrDenied + } + return c, nil +} + +func (s *Service) record(ctx context.Context, e Audit) error { + e.At = s.now() + if err := s.options.Audit.Record(ctx, e); err != nil { + return ErrUnavailable + } + return nil +} +func (s *Service) reject(ctx context.Context, operation string, g Grant, err error) error { + reason := "denied" + if err == ErrUnavailable { + reason = "unavailable" + } + if e := s.record(ctx, Audit{Operation: operation, Decision: "deny", Reason: reason, Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy}); e != nil { + return e + } + return err +} + +// ValidateGrantForStorage checks the structural grant and resource bounds. +// It permits expired or revoked rows so durable adapters can load historical +// state; it does not authorize access, apply the host policy, or approve a new +// grant. Create adapters must additionally require Version 1 and !Revoked. +func ValidateGrantForStorage(g Grant) error { + g.Revoked = false + if !validGrant(g, g.CreatedAt) { + return ErrDenied + } + return nil +} + +func validGrant(g Grant, now time.Time) bool { + if !identifier(g.ID) || !identifier(g.Subject) || !identifier(g.KeyID) || !identifier(g.ApprovedBy) || !httpsURL(g.AssertionIssuer) || !httpsURL(g.APIAudience) || !publicKey(g.PublicKey) || g.Version <= 0 || g.TenantID <= 0 || g.Revoked || g.CreatedAt.IsZero() || g.CreatedAt.After(now) || !g.ExpiresAt.After(now) || g.ExpiresAt.Sub(g.CreatedAt) > MaxGrantLifetime || !canonicalScopes(g.Scopes) { + return false + } + if slices.Contains(g.Scopes, PagesUpdate) && len(g.UpdatePageIDs) == 0 { + return false + } + seen := map[string]bool{} + for _, id := range g.UpdatePageIDs { + if !pageIdentifier(id) || seen[id] { + return false + } + seen[id] = true + } + if slices.Contains(g.Scopes, PreviewRead) { + if g.Preview == nil || !httpsOrigin(g.Preview.Origin) || !httpsURL(g.Preview.Audience) || g.Preview.Audience == g.APIAudience || len(g.Preview.Paths) == 0 { + return false + } + seen = map[string]bool{} + for _, p := range g.Preview.Paths { + if !previewTarget(p) || seen[p] { + return false + } + seen[p] = true + } + } else if g.Preview != nil { + return false + } + return true +} + +func audienceScopes(g Grant, a string, scopes []string) bool { + if !canonicalScopes(scopes) { + return false + } + for _, scope := range scopes { + if !slices.Contains(g.Scopes, scope) { + return false + } + if scope == PreviewRead { + if g.Preview == nil || a != g.Preview.Audience { + return false + } + } else if a != g.APIAudience { + return false + } + } + return true +} + +func resourceAllowed(g Grant, r Resource) bool { + switch r.Scope { + case PagesRead, HistoryRead: + return r.Query == "" && (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && r.Audience == g.APIAudience && (r.PageID == "" || pageIdentifier(r.PageID)) + case PagesUpdate: + return r.Query == "" && r.Method == "PUT" && r.RootPage && !r.HasCanonicalBlocks && r.ExpectedVersion > 0 && len(r.Fields) == 1 && r.Fields[0] == "body_html" && slices.Contains(g.UpdatePageIDs, r.PageID) && r.Audience == g.APIAudience + case PreviewRead: + target, valid := previewResourceTarget(r) + return valid && g.Preview != nil && r.Audience == g.Preview.Audience && r.Origin == g.Preview.Origin && (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && slices.Contains(g.Preview.Paths, target) + } + return false +} + +func httpsURL(raw string) bool { + u, err := url.Parse(raw) + return len(raw) > 0 && len(raw) <= 2048 && err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.RawPath == "" && (u.Path == "" || path.Clean(u.Path) == u.Path) && u.Host == strings.ToLower(u.Host) && u.String() == raw && !strings.ContainsAny(raw, "* \t\n\r\\") +} +func httpsOrigin(raw string) bool { + u, e := url.Parse(raw) + return e == nil && httpsURL(raw) && u.Path == "" +} +func identifier(s string) bool { + if len(s) == 0 || len(s) > 128 { + return false + } + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("-_.:", r)) { + return false + } + } + return true +} +func pageIdentifier(s string) bool { + id, err := strconv.ParseUint(s, 10, 64) + return err == nil && id > 0 && strconv.FormatUint(id, 10) == s +} +func previewPath(p string) bool { + if p == "" || !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.ContainsAny(p, "%?#\\\t\r\n ") { + return false + } + first := strings.ToLower(strings.Split(strings.TrimPrefix(p, "/"), "/")[0]) + return first != "admin" && first != "api" && first != "editor" && first != "oauth" && first != ".well-known" +} +func canonicalScopes(scopes []string) bool { + if len(scopes) == 0 || len(scopes) > 4 { + return false + } + seen := map[string]bool{} + for _, s := range scopes { + if seen[s] || (s != PagesRead && s != HistoryRead && s != PagesUpdate && s != PreviewRead) { + return false + } + seen[s] = true + } + return true +} +func sameScopes(a, b []string) bool { return len(a) == len(b) && slices.Equal(a, b) } +func exactAudience(a jwt.ClaimStrings, want string) bool { return len(a) == 1 && a[0] == want } +func shortTimes(i, e *jwt.NumericDate, now time.Time, max time.Duration) bool { + return i != nil && e != nil && !i.Time.After(now) && e.Time.After(now) && e.Time.After(i.Time) && e.Time.Sub(i.Time) <= max && now.Sub(i.Time) <= max +} +func publicKey(k *ecdsa.PublicKey) bool { + return k != nil && k.Curve == elliptic.P256() && k.X != nil && k.Y != nil && k.Curve.IsOnCurve(k.X, k.Y) +} +func digest(raw string) string { b := sha256.Sum256([]byte(raw)); return hex.EncodeToString(b[:]) } diff --git a/agentidentity/service_test.go b/agentidentity/service_test.go new file mode 100644 index 0000000..3744a86 --- /dev/null +++ b/agentidentity/service_test.go @@ -0,0 +1,547 @@ +package agentidentity + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testIssuer = "https://issuer.example.test" +const testEndpoint = "https://issuer.example.test/oauth/agent/token" +const testAPI = "https://preview.example.test/api" +const testView = "https://preview.example.test/view" + +type testStore struct { + mu sync.Mutex + grant Grant + grants map[string]Grant + uses map[string]bool + revoked map[string]bool + failRead, failReplay, failRevocation bool +} + +func (s *testStore) GetGrant(_ context.Context, id string) (Grant, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failRead { + return Grant{}, errors.New("STORE-SECRET-DO-NOT-LOG") + } + if s.grants != nil { + g, ok := s.grants[id] + if !ok { + return Grant{}, ErrDenied + } + return g, nil + } + if id != s.grant.ID { + return Grant{}, ErrDenied + } + return s.grant, nil +} +func (s *testStore) ConsumeAssertion(_ context.Context, u AssertionUse) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failReplay { + return false, errors.New("REPLAY-SECRET-DO-NOT-LOG") + } + g := s.grant + if s.grants != nil { + g = s.grants[u.GrantID] + } + if g.ID != u.GrantID || g.Version != u.GrantVersion || g.Revoked || !g.ExpiresAt.After(u.Now) { + return false, nil + } + key := u.JTIHash + if s.uses[key] { + return false, nil + } + s.uses[key] = true + return true, nil +} +func (s *testStore) IsTokenRevoked(_ context.Context, jti string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failRevocation { + return false, errors.New("REVOCATION-SECRET-DO-NOT-LOG") + } + return s.revoked[jti], nil +} + +type testAudit struct { + mu sync.Mutex + events []Audit + fail bool +} + +func (a *testAudit) Record(_ context.Context, e Audit) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.fail { + return errors.New("AUDIT-SECRET-DO-NOT-LOG") + } + a.events = append(a.events, e) + return nil +} + +type testPolicy struct{ deny bool } + +func (p *testPolicy) AllowGrant(_ context.Context, g Grant) error { + if p.deny || g.TenantID != 234 || g.APIAudience != testAPI || (g.Preview != nil && (g.Preview.Origin != "https://preview.example.test" || g.Preview.Audience != testView)) { + return ErrDenied + } + for _, id := range g.UpdatePageIDs { + if id != "3" && id != "6" { + return ErrDenied + } + } + return nil +} +func (p *testPolicy) AllowResource(_ context.Context, _ Grant, r Resource) error { + if p.deny || r.TenantID != 234 || strings.Contains(r.Path, "auth") || strings.Contains(r.Path, "platform") || strings.Contains(r.Path, "domains") { + return ErrDenied + } + return nil +} + +type testIssuerAdapter struct { + key *ecdsa.PrivateKey + mutate func(*accessClaims) + fail bool +} + +func (i *testIssuerAdapter) IssueAgentToken(_ context.Context, r IssueRequest) (string, error) { + if i.fail { + return "", errors.New("ISSUER-SECRET-DO-NOT-LOG") + } + c := accessClaims{RegisteredClaims: jwt.RegisteredClaims{Issuer: testIssuer, Subject: r.Subject, Audience: jwt.ClaimStrings{r.Audience}, IssuedAt: jwt.NewNumericDate(r.IssuedAt), ExpiresAt: jwt.NewNumericDate(r.ExpiresAt), ID: "test-issued-jti"}, TokenUse: "agent_access", GrantID: r.GrantID, GrantVersion: r.GrantVersion, TenantID: r.TenantID, Scope: strings.Join(r.Scopes, " ")} + if i.mutate != nil { + i.mutate(&c) + } + return sign(i.key, c, "issuer-key") +} +func sign(k *ecdsa.PrivateKey, c jwt.Claims, kid string) (string, error) { + t := jwt.NewWithClaims(jwt.SigningMethodES256, c) + t.Header["kid"] = kid + return t.SignedString(k) +} + +type fixture struct { + s *Service + store *testStore + audit *testAudit + issuer *testIssuerAdapter + workload *ecdsa.PrivateKey + now time.Time + policy *testPolicy +} + +func setup(t *testing.T) *fixture { + t.Helper() + k, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if e != nil { + t.Fatal(e) + } + w, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if e != nil { + t.Fatal(e) + } + now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + g := Grant{ID: "grant-1", Subject: "agent-tina-preview", AssertionIssuer: "https://workload.example.test", KeyID: "workload-key", PublicKey: &w.PublicKey, Version: 1, TenantID: 234, ApprovedBy: "human-owner", CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(24 * time.Hour), APIAudience: testAPI, Scopes: []string{PagesRead, HistoryRead, PagesUpdate, PreviewRead}, UpdatePageIDs: []string{"3", "6"}, Preview: &PreviewGrant{Audience: testView, Origin: "https://preview.example.test", Paths: []string{"/", "/rock-metal", "/contact", "/assets/tina.css"}}} + f := &fixture{store: &testStore{grant: g, uses: map[string]bool{}, revoked: map[string]bool{}}, audit: &testAudit{}, issuer: &testIssuerAdapter{key: k}, workload: w, now: now, policy: &testPolicy{}} + s, e := NewService(Options{IssuerURL: testIssuer, TokenEndpoint: testEndpoint, IssuerKeys: map[string]*ecdsa.PublicKey{"issuer-key": &k.PublicKey}, Store: f.store, Issuer: f.issuer, Policy: f.policy, Audit: f.audit, Clock: func() time.Time { return f.now }}) + if e != nil { + t.Fatal(e) + } + f.s = s + return f +} +func (f *fixture) assertion(t *testing.T, id string, mutate func(*jwt.RegisteredClaims)) string { + t.Helper() + c := jwt.RegisteredClaims{Issuer: f.store.grant.AssertionIssuer, Subject: f.store.grant.Subject, Audience: jwt.ClaimStrings{testEndpoint}, IssuedAt: jwt.NewNumericDate(f.now), ExpiresAt: jwt.NewNumericDate(f.now.Add(time.Minute)), ID: id} + if mutate != nil { + mutate(&c) + } + raw, e := sign(f.workload, c, "workload-key") + if e != nil { + t.Fatal(e) + } + return raw +} +func (f *fixture) exchange(t *testing.T, scopes []string, aud string) TokenResponse { + t.Helper() + out, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "assertion-1", nil), Audience: aud, Scopes: scopes}) + if e != nil { + t.Fatal(e) + } + return out +} +func apiRead() Resource { + return Resource{TenantID: 234, Audience: testAPI, Scope: PagesRead, Method: "GET", Path: "/api/v1/admin/tenants/234/pages"} +} +func apiUpdate() Resource { + return Resource{TenantID: 234, Audience: testAPI, Scope: PagesUpdate, PageID: "3", Method: "PUT", Fields: []string{"body_html"}, ExpectedVersion: 3, RootPage: true, Path: "/api/v1/admin/tenants/234/pages/3"} +} + +func TestExchangeAndLeastPrivilegeResource(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PagesRead, HistoryRead, PagesUpdate}, testAPI) + if out.TokenType != "Bearer" || out.ExpiresIn != 900 { + t.Fatal("incorrect bounded response") + } + for _, r := range []Resource{apiRead(), apiUpdate(), {TenantID: 234, Audience: testAPI, Scope: HistoryRead, Method: "GET", Path: "/api/v1/admin/tenants/234/pages/history"}} { + p, e := f.s.Authorize(context.Background(), out.AccessToken, r) + if e != nil || p.Subject != "agent-tina-preview" || p.TenantID != 234 || p.JTIHash == "test-issued-jti" { + t.Fatalf("typed principal missing: %v", e) + } + } + var parsed accessClaims + _, _, e := jwt.NewParser().ParseUnverified(out.AccessToken, &parsed) + if e != nil || parsed.TokenUse != "agent_access" { + t.Fatal("missing service token discriminator") + } +} + +func TestAssertionBoundaries(t *testing.T) { + cases := map[string]func(*jwt.RegisteredClaims){ + "issuer": func(c *jwt.RegisteredClaims) { c.Issuer = "https://other.example.test" }, + "subject": func(c *jwt.RegisteredClaims) { c.Subject = "human-owner" }, + "audience": func(c *jwt.RegisteredClaims) { c.Audience = jwt.ClaimStrings{testAPI} }, + "multiple audiences": func(c *jwt.RegisteredClaims) { c.Audience = append(c.Audience, testAPI) }, + "expiry required": func(c *jwt.RegisteredClaims) { c.ExpiresAt = nil }, + "iat required": func(c *jwt.RegisteredClaims) { c.IssuedAt = nil }, + "jti required": func(c *jwt.RegisteredClaims) { c.ID = "" }, + "expired": func(c *jwt.RegisteredClaims) { c.ExpiresAt = c.IssuedAt }, + "future": func(c *jwt.RegisteredClaims) { c.IssuedAt = jwt.NewNumericDate(c.IssuedAt.Add(time.Second)) }, + "overlong": func(c *jwt.RegisteredClaims) { c.ExpiresAt = jwt.NewNumericDate(c.IssuedAt.Add(61 * time.Second)) }, + "stale": func(c *jwt.RegisteredClaims) { c.IssuedAt = jwt.NewNumericDate(c.IssuedAt.Add(-61 * time.Second)) }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + f := setup(t) + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", mutate), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrDenied || len(f.store.uses) != 0 { + t.Fatal("invalid assertion consumed/accepted", e) + } + }) + } + for _, kind := range []string{"wrong-key", "wrong-kid", "HS256", "none"} { + t.Run(kind, func(t *testing.T) { + f := setup(t) + raw := f.assertion(t, "jti", nil) + c := jwt.MapClaims{} + _, _, _ = jwt.NewParser().ParseUnverified(raw, c) + switch kind { + case "wrong-key": + raw, _ = sign(f.issuer.key, c, "workload-key") + case "wrong-kid": + raw, _ = sign(f.workload, c, "other-key") + case "HS256": + raw, _ = jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString([]byte("test-only-secret")) + case "none": + raw, _ = jwt.NewWithClaims(jwt.SigningMethodNone, c).SignedString(jwt.UnsafeAllowNoneSignatureType) + } + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: raw, Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrDenied { + t.Fatal("untrusted assertion accepted", e) + } + }) + } +} + +func TestScopesAndCrossTenant(t *testing.T) { + for _, scopes := range [][]string{nil, {"cms.admin"}, {"admin:auth.users:write"}, {PagesRead, PagesRead}, {PreviewRead, PagesRead}, {"admin:multisite.pages:create"}} { + t.Run(strings.Join(scopes, "-"), func(t *testing.T) { + f := setup(t) + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", nil), Audience: testAPI, Scopes: scopes}) + if e != ErrDenied { + t.Fatal("excess/ambiguous scopes accepted") + } + }) + } + for _, mutation := range []func(*Grant){func(g *Grant) { g.TenantID = 235 }, func(g *Grant) { g.Scopes = []string{PagesRead}; g.Preview = nil }, func(g *Grant) { g.UpdatePageIDs = []string{"999"} }, func(g *Grant) { g.ExpiresAt = g.CreatedAt.Add(MaxGrantLifetime + time.Second) }, func(g *Grant) { g.Revoked = true }} { + f := setup(t) + mutation(&f.store.grant) + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", nil), Audience: testAPI, Scopes: []string{PagesUpdate}}) + if e != ErrDenied { + t.Fatal("changed grant accepted", e) + } + } + f := setup(t) + out := f.exchange(t, []string{PagesRead}, testAPI) + r := apiRead() + r.TenantID = 235 + if _, e := f.s.Authorize(context.Background(), out.AccessToken, r); e != ErrDenied { + t.Fatal("cross tenant allowed") + } +} + +func TestReplayAtomicAcrossServiceInstances(t *testing.T) { + f := setup(t) + other, e := NewService(f.s.options) + if e != nil { + t.Fatal(e) + } + in := ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "one-use", nil), Audience: testAPI, Scopes: []string{PagesRead}} + var wg sync.WaitGroup + success := make(chan bool, 12) + for n := 0; n < 12; n++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + s := f.s + if n%2 == 0 { + s = other + } + _, e := s.Exchange(context.Background(), in) + success <- e == nil + }(n) + } + wg.Wait() + close(success) + count := 0 + for ok := range success { + if ok { + count++ + } + } + if count != 1 { + t.Fatalf("replayed assertion issued %d times", count) + } +} + +func TestTokenAndGrantCurrentState(t *testing.T) { + for _, name := range []string{"token-expired", "grant-expired", "grant-revoked", "version-changed", "token-revoked", "read-unavailable", "revocation-unavailable", "audit-unavailable", "policy-removed"} { + t.Run(name, func(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PagesRead}, testAPI) + switch name { + case "token-expired": + f.now = f.now.Add(MaxTokenLifetime) + case "grant-expired": + f.store.grant.ExpiresAt = f.now + case "grant-revoked": + f.store.grant.Revoked = true + case "version-changed": + f.store.grant.Version++ + case "token-revoked": + f.store.revoked[digest("test-issued-jti")] = true + case "read-unavailable": + f.store.failRead = true + case "revocation-unavailable": + f.store.failRevocation = true + case "audit-unavailable": + f.audit.fail = true + case "policy-removed": + f.policy.deny = true + } + // A reconstructed service consults the same authoritative adapter; no + // local token/revocation cache can undo a persisted decision. + restarted, e := NewService(f.s.options) + if e != nil { + t.Fatal(e) + } + _, e = restarted.Authorize(context.Background(), out.AccessToken, apiRead()) + if e == nil { + t.Fatal("current-state denial lost after reconstruction") + } + }) + } +} + +func TestStorageValidationRetainsHistoricalRowsWithoutAuthorizingThem(t *testing.T) { + f := setup(t) + g := f.store.grant + g.Revoked = true + if err := ValidateGrantForStorage(g); err != nil || !g.Revoked { + t.Fatal("revoked durable row rejected or mutated", err) + } + g.Revoked = false + g.CreatedAt = f.now.Add(-2 * time.Hour) + g.ExpiresAt = f.now.Add(-time.Hour) + if err := ValidateGrantForStorage(g); err != nil || validGrant(g, f.now) { + t.Fatal("historical structural check became access admission", err) + } + for _, mutate := range []func(*Grant){ + func(g *Grant) { g.CreatedAt = time.Time{} }, + func(g *Grant) { g.ExpiresAt = g.CreatedAt }, + func(g *Grant) { g.ExpiresAt = g.CreatedAt.Add(MaxGrantLifetime + time.Second) }, + func(g *Grant) { g.Scopes = []string{"admin:auth.users:update"} }, + func(g *Grant) { g.PublicKey = nil }, + func(g *Grant) { g.Version = 0 }, + } { + bad := g + mutate(&bad) + if err := ValidateGrantForStorage(bad); err != ErrDenied { + t.Fatal("malformed durable grant accepted", err) + } + } +} + +func TestBodyOnlyRootPageBoundary(t *testing.T) { + cases := map[string]func(*Resource){ + "page": func(r *Resource) { r.PageID = "4" }, "version": func(r *Resource) { r.ExpectedVersion = 0 }, "blocks": func(r *Resource) { r.HasCanonicalBlocks = true }, "overlay": func(r *Resource) { r.RootPage = false }, "status": func(r *Resource) { r.Fields = append(r.Fields, "status") }, "only status": func(r *Resource) { r.Fields = []string{"status"} }, "delete": func(r *Resource) { r.Method = "DELETE" }, "create": func(r *Resource) { r.Scope = "admin:multisite.pages:create" }, "live": func(r *Resource) { r.TenantID = 42 }, "users": func(r *Resource) { r.Path = "/api/v1/admin/auth/users" }, "read token": func(r *Resource) { r.Scope = PagesRead }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PagesUpdate}, testAPI) + r := apiUpdate() + mutate(&r) + if _, e := f.s.Authorize(context.Background(), out.AccessToken, r); e != ErrDenied { + t.Fatal("forbidden operation authorized", e) + } + }) + } + f := setup(t) + out := f.exchange(t, []string{PagesRead}, testAPI) + if _, e := f.s.Authorize(context.Background(), out.AccessToken, apiUpdate()); e != ErrDenied { + t.Fatal("read-only token wrote") + } +} + +func TestSeparatePreviewPlane(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PreviewRead}, testView) + r := Resource{TenantID: 234, Audience: testView, Scope: PreviewRead, Method: "GET", Origin: "https://preview.example.test", Path: "/rock-metal"} + if _, e := f.s.Authorize(context.Background(), out.AccessToken, r); e != nil { + t.Fatal(e) + } + for _, mutate := range []func(*Resource){func(r *Resource) { r.Audience = testAPI }, func(r *Resource) { r.Origin = "https://alias.example.test" }, func(r *Resource) { r.Path = "/admin" }, func(r *Resource) { r.Path = "/api/v1/admin" }, func(r *Resource) { r.Path = "/editor" }, func(r *Resource) { r.Path = "/oauth/token" }, func(r *Resource) { r.Path = "/%61dmin" }, func(r *Resource) { r.Path = "/unlisted" }, func(r *Resource) { r.Method = "POST" }, func(r *Resource) { r.TenantID = 235 }} { + c := r + mutate(&c) + if _, e := f.s.Authorize(context.Background(), out.AccessToken, c); e != ErrDenied { + t.Fatal("preview scope widened", e) + } + } + if _, e := f.s.Authorize(context.Background(), out.AccessToken, apiRead()); e != ErrDenied { + t.Fatal("view token accepted on API") + } +} + +func TestIssuerOutputStrictAndRedactedFailures(t *testing.T) { + for name, mutate := range map[string]func(*accessClaims){"audience": func(c *accessClaims) { c.Audience = jwt.ClaimStrings{testView} }, "issuer": func(c *accessClaims) { c.Issuer = "https://other.example.test" }, "subject": func(c *accessClaims) { c.Subject = "human-owner" }, "scope": func(c *accessClaims) { c.Scope = PagesUpdate }, "grant": func(c *accessClaims) { c.GrantVersion++ }, "tenant": func(c *accessClaims) { c.TenantID = 235 }, "expiry": func(c *accessClaims) { c.ExpiresAt = jwt.NewNumericDate(c.ExpiresAt.Add(time.Second)) }, "human": func(c *accessClaims) { c.TokenUse = "human_session" }} { + t.Run(name, func(t *testing.T) { + f := setup(t) + f.issuer.mutate = mutate + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrUnavailable { + t.Fatal("unsafe issuer output disclosed", e) + } + }) + } + f := setup(t) + f.store.failRead = true + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: "ASSERTION-SECRET-DO-NOT-LOG", Audience: testAPI, Scopes: []string{PagesRead}}) + b, _ := json.Marshal(f.audit.events) + if e != ErrUnavailable || strings.Contains(string(b), "SECRET") || strings.Contains(e.Error(), "SECRET") { + t.Fatal("audit/error disclosed sensitive input") + } + for _, kind := range []string{"replay", "revocation", "audit", "issuer"} { + t.Run(kind, func(t *testing.T) { + f := setup(t) + switch kind { + case "replay": + f.store.failReplay = true + case "revocation": + f.store.failRevocation = true + case "audit": + f.audit.fail = true + case "issuer": + f.issuer.fail = true + } + out, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "RAW-JTI", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrUnavailable || out.AccessToken != "" || strings.Contains(e.Error(), "SECRET") { + t.Fatal("storage/issuer failure failed open") + } + b, _ := json.Marshal(f.audit.events) + if strings.Contains(string(b), "RAW-JTI") || strings.Contains(string(b), "SECRET") { + t.Fatal("raw auth data logged") + } + }) + } +} + +func TestGrantCapsTokenAtFiniteExpiry(t *testing.T) { + f := setup(t) + f.store.grant.ExpiresAt = f.now.Add(45 * time.Second) + out := f.exchange(t, []string{PagesRead}, testAPI) + if out.ExpiresIn != 45 { + t.Fatal("grant expiry not applied to issued token") + } +} + +func TestMountedExchangeHTTPBoundary(t *testing.T) { + f := setup(t) + form := url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, "grant_id": {"grant-1"}, "assertion": {f.assertion(t, "http-jti", nil)}, "audience": {testAPI}, "scope": {PagesRead}} + server := httptest.NewServer(f.s.ExchangeHandler()) + defer server.Close() + response, e := http.Post(server.URL+"/oauth/agent/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if e != nil { + t.Fatal(e) + } + defer response.Body.Close() + var result TokenResponse + if e = json.NewDecoder(response.Body).Decode(&result); e != nil || response.StatusCode != 200 || result.AccessToken == "" || response.Header.Get("Cache-Control") != "private, no-store" { + t.Fatal("mounted exchange failure", e, response.StatusCode) + } + for _, kind := range []string{"cookie", "basic", "query", "duplicate", "refresh", "extra", "alias", "GET"} { + t.Run(kind, func(t *testing.T) { + copyForm := url.Values{} + for k, v := range form { + copyForm[k] = append([]string(nil), v...) + } + p := "/oauth/agent/token" + method := "POST" + switch kind { + case "query": + p += "?grant_id=grant-1" + case "duplicate": + copyForm.Add("grant_id", "grant-1") + case "refresh": + copyForm.Set("grant_type", "refresh_token") + case "extra": + copyForm.Set("role", "super_admin") + case "alias": + p = "/other/oauth/agent/token" + case "GET": + method = "GET" + } + r := httptest.NewRequest(method, p, strings.NewReader(copyForm.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if kind == "cookie" { + r.Header.Set("Cookie", "personal=SECRET") + } + if kind == "basic" { + r.SetBasicAuth("human", "SECRET") + } + w := httptest.NewRecorder() + f.s.ExchangeHandler().ServeHTTP(w, r) + if w.Code < 400 || strings.Contains(w.Body.String(), "SECRET") { + t.Fatal("alternate auth/request accepted", w.Code) + } + }) + } +} + +func TestConstructorHasNoInsecureFallback(t *testing.T) { + f := setup(t) + for _, mutate := range []func(*Options){func(o *Options) { o.Store = nil }, func(o *Options) { o.Audit = nil }, func(o *Options) { o.Policy = nil }, func(o *Options) { o.Issuer = nil }, func(o *Options) { o.IssuerURL = "http://issuer.example.test" }, func(o *Options) { o.TokenEndpoint = "" }, func(o *Options) { o.IssuerKeys = nil }} { + o := f.s.options + mutate(&o) + if _, e := NewService(o); e != ErrUnavailable { + t.Fatal("insecure constructor accepted") + } + } +} diff --git a/agentidentity/types.go b/agentidentity/types.go new file mode 100644 index 0000000..63219ce --- /dev/null +++ b/agentidentity/types.go @@ -0,0 +1,191 @@ +// Package agentidentity implements opt-in, tenant-scoped service grants. Hosts +// supply durable adapters and Workflow's managed issuer; no human sessions or +// issuer/private keys are created by this package. +package agentidentity + +import ( + "context" + "crypto/ecdsa" + "errors" + "net/http" + "time" +) + +const ( + PagesRead = "admin:multisite.pages:read" + HistoryRead = "admin:multisite.pages:history:read" + PagesUpdate = "admin:multisite.pages:update" + PreviewRead = "admin:multisite.preview:read" + MaxGrantLifetime = 30 * 24 * time.Hour + MaxTokenLifetime = 15 * time.Minute + MaxAssertionLifetime = time.Minute +) + +var ( + ErrDenied = errors.New("agent access denied") + ErrUnavailable = errors.New("agent access unavailable") + ErrConflict = errors.New("agent grant conflict") +) + +type PreviewGrant struct { + Audience string `json:"audience"` + Origin string `json:"origin"` + // Paths are exact canonical request targets, optionally including a finite + // literal query. A bare path never grants its query variants. + Paths []string `json:"paths"` +} + +// Grant is a host-owned durable row. Rotation and any permission edit MUST +// increment Version atomically; revocation must not delete the audit record. +// The public assertion key is not issuer key material or a human credential. +type Grant struct { + ID string `json:"id"` + Subject string `json:"subject"` + AssertionIssuer string `json:"assertion_issuer"` + KeyID string `json:"key_id"` + PublicKey *ecdsa.PublicKey `json:"-"` + Version int64 `json:"version"` + TenantID int64 `json:"tenant_id"` + ApprovedBy string `json:"approved_by"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked"` + APIAudience string `json:"api_audience"` + Scopes []string `json:"scopes"` + UpdatePageIDs []string `json:"update_page_ids,omitempty"` + Preview *PreviewGrant `json:"preview,omitempty"` +} + +type AssertionUse struct { + GrantID string + GrantVersion int64 + JTIHash string + ExpiresAt time.Time + Now time.Time +} + +// Store methods MUST use durable state. ConsumeAssertion atomically checks the +// current grant version, enabled/expiry state AND inserts its globally unique +// assertion identity hash (issuer/subject/key/JTI binding). Uniqueness MUST NOT +// be partitioned by grant/version: one assertion cannot be reused on another +// tenant grant or after a permission edit. Return false for replay or changed/ +// revoked grants. Create and Revoke must be +// transactional and never alter a human identity/factor/bootstrap record. +// No in-memory production fallback is supplied. +type Store interface { + GetGrant(context.Context, string) (Grant, error) + ConsumeAssertion(context.Context, AssertionUse) (bool, error) + IsTokenRevoked(context.Context, string) (bool, error) +} + +type GrantManager interface { + CreateGrant(context.Context, Grant) error + RevokeGrant(context.Context, string, int64, string, time.Time) error + ListGrants(context.Context, Owner) ([]Grant, error) +} + +type Owner struct{ ID string } + +// OwnerPolicy must resolve a verified HUMAN owner and explicitly authorize the +// proposed tenant/resource/field ceiling. Request JSON never supplies identity. +// A service principal must not be projected into this resolver. +type OwnerPolicy interface { + ResolveOwner(*http.Request) (Owner, error) + AuthorizeOwner(context.Context, Owner, string, Grant) error +} + +// Policy is the consuming app's exact tenant/resource allowlist. It is required +// even for already persisted grants: the library does not know which tenant is +// live or which routes exist. It must reject live tenants and privileged routes +// for the preview assistant. An omitted policy never grants access. +type Policy interface { + AllowGrant(context.Context, Grant) error + AllowResource(context.Context, Grant, Resource) error +} + +type IssueRequest struct { + Subject, Audience, GrantID string + GrantVersion, TenantID int64 + Scopes []string + IssuedAt, ExpiresAt time.Time +} + +// Issuer is an adapter to Workflow M2M IssueManagedToken with managed-only +// issuance and an explicitly configured stable ES256 key. Hosts map this typed +// request; never implement this with a human cookie/HMAC or generic OAuth route. +type Issuer interface { + IssueAgentToken(context.Context, IssueRequest) (string, error) +} + +type IssuerFunc func(context.Context, IssueRequest) (string, error) + +func (f IssuerFunc) IssueAgentToken(ctx context.Context, r IssueRequest) (string, error) { + return f(ctx, r) +} + +// Audit contains fixed reason codes and verified metadata, never raw tokens, +// assertions, keys, headers, bodies, arbitrary error strings or human emails. +type Audit struct { + At time.Time `json:"at"` + Operation string `json:"operation"` + Decision string `json:"decision"` + Reason string `json:"reason"` + Subject string `json:"subject,omitempty"` + GrantID string `json:"grant_id,omitempty"` + GrantVersion int64 `json:"grant_version,omitempty"` + TenantID int64 `json:"tenant_id,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + PageID string `json:"page_id,omitempty"` + Scope string `json:"scope,omitempty"` + JTIHash string `json:"jti_hash,omitempty"` +} +type Auditor interface { + Record(context.Context, Audit) error +} + +type Options struct { + IssuerURL string + TokenEndpoint string + IssuerKeys map[string]*ecdsa.PublicKey + Store Store + Issuer Issuer + Policy Policy + Audit Auditor + // Clock is for deterministic tests; production hosts normally omit it. + Clock func() time.Time +} + +type ExchangeRequest struct { + GrantID string + Assertion string + Audience string + Scopes []string +} +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` +} + +// Resource must be built from the matched route/current saved row by the host, +// never copied unverified from JSON or X-Tenant/actor headers. All writes are +// body_html-only root-page CAS; canonical blocks are refused. +type Resource struct { + TenantID int64 + Audience, Scope, PageID string + Fields []string + ExpectedVersion uint64 + RootPage bool + HasCanonicalBlocks bool + Method, Origin, Path string + // Query is the raw canonical query of a preview request only. Path stays + // the matched path; API/history query authorization remains host-owned. + Query string +} + +type Principal struct { + Subject, GrantID, JTIHash string + GrantVersion, TenantID int64 + Scopes []string +} diff --git a/agentidentity/ui.go b/agentidentity/ui.go new file mode 100644 index 0000000..bf9b286 --- /dev/null +++ b/agentidentity/ui.go @@ -0,0 +1,45 @@ +package agentidentity + +import ( + "encoding/json" + "fmt" +) + +func managementHTML(o ManagementOptions, tenants []TenantConfig) ([]byte, error) { + payload, err := json.Marshal(struct { + APIPath string `json:"api_path"` + Tenants []TenantConfig `json:"tenants"` + }{o.Path, tenants}) + if err != nil { + return nil, err + } + return []byte(fmt.Sprintf(managementTemplate, payload)), nil +} + +const managementTemplate = `
Approve a separate assistant for a preview site. Access expires automatically and can be revoked here. Your personal account and login stay separate.
+