From aa5a97267cf5e607e468aa1d62b4fbdcfd5f745c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 14:17:26 -0400 Subject: [PATCH 1/4] fix: honor wrapped preview policy in owner agent UI --- agentidentity/http.go | 19 ++++- agentidentity/http_test.go | 147 +++++++++++++++++++++++++++++++++++ docs/agentidentity/README.md | 8 ++ 3 files changed, 171 insertions(+), 3 deletions(-) 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/docs/agentidentity/README.md b/docs/agentidentity/README.md index 125f2c1..0d42fd8 100644 --- a/docs/agentidentity/README.md +++ b/docs/agentidentity/README.md @@ -77,6 +77,11 @@ revocation, never silent key generation. This package is not another issuer/IDP. for current tenant authority. POSTs require exact Origin/Host and same-origin fetch metadata; agent bearers cannot be projected into the human resolver. Ordinary owner cookies stay in the browser and are never exported to an agent. + Hosts wrapping the required service `Policy` with current preview-domain + checks can supply `ManagementOptions.Tenants` for immutable UI declarations. + Each displayed tenant/audience proposal still passes that wrapped policy and + owner authorization. If omitted, declarations come from `Policy` when it is a + `*TenantPolicy`; this option never replaces the service's authority. 3. Select a configured preview, stable assistant subject and registered public workload key. Approve finite expiry and permissions. Read/history are selected initially; body edits and protected viewing are separate unchecked options. @@ -89,6 +94,9 @@ revocation, never silent key generation. This package is not another issuer/IDP. 5. Revoke from the same page. The durable store checks the loaded grant version and records the change transactionally. Rotation uses a separately approved public key/new grant and revokes the old grant; there is no implicit renewal. + Revocation also requires the current service policy. If its preview gate is + closed, owner cleanup requires a current approved preview declaration or a + separately approved offline cleanup; the handler never bypasses that gate. For an approved local workload, existing `wfctl secrets set --provider keychain --service ` offers hidden entry, and `--from-file` From dfb9821c583653cd206eacc8e21f41f33fb10c50 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 10 Oct 2026 16:28:27 -0400 Subject: [PATCH 2/4] fix: wrap long scope boundaries in agent owner UI --- agentidentity/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentidentity/ui.go b/agentidentity/ui.go index 5ab71db..2f5a859 100644 --- a/agentidentity/ui.go +++ b/agentidentity/ui.go @@ -17,7 +17,7 @@ func managementHTML(o ManagementOptions, tenants []TenantConfig) ([]byte, error) } const managementTemplate = `Agent access - +

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.

Approve access

From fc6471e910bbb069b3c9d00905eae7cfc15e7f11 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 10 Oct 2026 17:04:43 -0400 Subject: [PATCH 3/4] feat(agentidentity): bind exact preview query targets to current grants --- agentidentity/policy.go | 7 +- agentidentity/preview_target.go | 53 ++++++++++++++ agentidentity/preview_target_test.go | 106 +++++++++++++++++++++++++++ agentidentity/service.go | 9 ++- agentidentity/types.go | 11 ++- docs/agentidentity/README.md | 13 +++- 6 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 agentidentity/preview_target.go create mode 100644 agentidentity/preview_target_test.go 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/docs/agentidentity/README.md b/docs/agentidentity/README.md index 0d42fd8..2914a34 100644 --- a/docs/agentidentity/README.md +++ b/docs/agentidentity/README.md @@ -35,7 +35,18 @@ require PUT, the granted root page, exactly `body_html`, positive loaded library cannot manufacture one. Reads cannot become writes, and no create/delete, publish-status, schedule, identity, bootstrap, platform, domain, reload or live authority is supported. Preview access permits exact-origin GET/HEAD only on -declared paths and never forwards a token to another origin. +declared targets and never forwards a token to another origin. + +`PreviewGrant.Paths` contains finite exact canonical request targets. For example, +`/contact?interest=voice-study` must be listed explicitly; `/contact` alone grants +no query variants. Hosts build preview resources with the actual matched +`Resource.Path` and literal raw `Resource.Query`. The complete target must match +both the current persisted grant and the tenant declaration. Targets are bounded +to 2048 bytes, queries to 512 bytes and eight unique sorted keys; names and values +use unreserved ASCII, with no empty values, encoding aliases or fragments. API +and saved-history query handling remains separate and unchanged. The CMS gate +also needs its finite per-tenant query ceiling before a queried target can reach +this service's authorization callback. ## Workflow issuer adapter From 47338ad69fd10eb10a1f5eb915e64e8248e4c50b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 10 Oct 2026 17:57:19 -0400 Subject: [PATCH 4/4] fix(agentidentity): review exact UTC grant expiry with one-day default --- agentidentity/ui.go | 12 +++++++----- docs/operations/agent-owner-expiry.md | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 docs/operations/agent-owner-expiry.md diff --git a/agentidentity/ui.go b/agentidentity/ui.go index 2f5a859..bf9b286 100644 --- a/agentidentity/ui.go +++ b/agentidentity/ui.go @@ -25,19 +25,21 @@ const managementTemplate = ` Use the public key from the approved protected workload. Its private key stays in the configured protected store; it is never entered here. - +

Grant expiry:

Permissions

Current grants

    ` diff --git a/docs/operations/agent-owner-expiry.md b/docs/operations/agent-owner-expiry.md new file mode 100644 index 0000000..deb13a6 --- /dev/null +++ b/docs/operations/agent-owner-expiry.md @@ -0,0 +1,21 @@ +# Review the exact grant expiry + +The native owner page defaults to **1 day**. It shows the proposed absolute UTC +expiry before confirmation and submits that same instant, without silently +recalculating it when the form is submitted. Proposed times use whole seconds. +Changing the lifetime clears confirmation and shows a new expiry; changing the +tenant also clears confirmation. An expired proposal is refused without a POST. +Select the lifetime again and review the new instant before approving. + +The proposed lifetime starts when the displayed expiry is calculated. Time +spent completing the form reduces the remaining lifetime; it does not extend +the grant. Retained grants also display absolute UTC expiry, regardless of the +browser's timezone. Existing authenticated owner authorization, durable policy, +grant creation and revocation limits still apply. + +For the one-day preview proposal, fresh public grant readback must establish the +actual ID, version, creation and expiry times and exact approved authority. +Approve the resulting public client profile before activation. A clock mismatch +or readback exceeding the approved one-day ceiling must stop activation and be +reconciled; do not widen the profile. This display repair creates no persistent +key, grant, credential configuration or workload activation.