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/agentidentity/http.go b/agentidentity/http.go index e7bd3d5..17e80fb 100644 --- a/agentidentity/http.go +++ b/agentidentity/http.go @@ -71,6 +71,11 @@ type ManagementOptions struct { 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 { @@ -124,14 +129,18 @@ func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { w.WriteHeader(http.StatusMethodNotAllowed) return } - policy, ok := s.options.Policy.(*TenantPolicy) - if !ok { + 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() { - if o.Owners.AuthorizeOwner(r.Context(), owner, "propose", Grant{TenantID: c.TenantID, APIAudience: c.APIAudience}) == nil { + 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) } } @@ -211,6 +220,10 @@ func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { 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 diff --git a/agentidentity/http_test.go b/agentidentity/http_test.go index 00bdb86..3bce97c 100644 --- a/agentidentity/http_test.go +++ b/agentidentity/http_test.go @@ -230,3 +230,150 @@ func TestOwnerPageUsesConfiguredTenantsAndExistingSession(t *testing.T) { 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 index c710a04..b9dc21d 100644 --- a/agentidentity/policy.go +++ b/agentidentity/policy.go @@ -68,7 +68,7 @@ func NewTenantPolicy(config []TenantConfig) (*TenantPolicy, error) { } seen = map[string]bool{} for _, route := range c.Preview.Paths { - if !previewPath(route) || seen[route] { + if !previewTarget(route) || seen[route] { return nil, ErrUnavailable } seen[route] = true @@ -122,7 +122,8 @@ func (p *TenantPolicy) AllowResource(ctx context.Context, g Grant, r Resource) e } c := p.tenants[g.TenantID] if r.Scope == PreviewRead { - if c.Preview == nil || r.Origin != c.Preview.Origin || !slices.Contains(c.Preview.Paths, r.Path) { + target, valid := previewResourceTarget(r) + if !valid || c.Preview == nil || r.Origin != c.Preview.Origin || !slices.Contains(c.Preview.Paths, target) { return ErrDenied } return nil @@ -130,7 +131,7 @@ func (p *TenantPolicy) AllowResource(ctx context.Context, g Grant, r Resource) e // 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 strings.ContainsAny(r.Path, "%?#\\") || path.Clean(r.Path) != r.Path { + if r.Query != "" || strings.ContainsAny(r.Path, "%?#\\") || path.Clean(r.Path) != r.Path { return ErrDenied } switch r.Scope { 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 index ad352ff..cd0e4c5 100644 --- a/agentidentity/service.go +++ b/agentidentity/service.go @@ -226,7 +226,7 @@ func validGrant(g Grant, now time.Time) bool { } seen = map[string]bool{} for _, p := range g.Preview.Paths { - if !previewPath(p) || seen[p] { + if !previewTarget(p) || seen[p] { return false } seen[p] = true @@ -259,11 +259,12 @@ func audienceScopes(g Grant, a string, scopes []string) bool { func resourceAllowed(g Grant, r Resource) bool { switch r.Scope { case PagesRead, HistoryRead: - return (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && r.Audience == g.APIAudience && (r.PageID == "" || pageIdentifier(r.PageID)) + 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.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 + 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: - return g.Preview != nil && r.Audience == g.Preview.Audience && r.Origin == g.Preview.Origin && (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && previewPath(r.Path) && slices.Contains(g.Preview.Paths, r.Path) + 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 } diff --git a/agentidentity/types.go b/agentidentity/types.go index 6fe1a34..63219ce 100644 --- a/agentidentity/types.go +++ b/agentidentity/types.go @@ -28,9 +28,11 @@ var ( ) type PreviewGrant struct { - Audience string `json:"audience"` - Origin string `json:"origin"` - Paths []string `json:"paths"` + 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 @@ -177,6 +179,9 @@ type Resource struct { 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 { diff --git a/agentidentity/ui.go b/agentidentity/ui.go index 5ab71db..bf9b286 100644 --- a/agentidentity/ui.go +++ b/agentidentity/ui.go @@ -17,7 +17,7 @@ func managementHTML(o ManagementOptions, tenants []TenantConfig) ([]byte, error) } 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.