Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions agentidentity/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions agentidentity/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := &currentPreviewPolicyFixture{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 := &currentPreviewPolicyFixture{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)
}
}
7 changes: 4 additions & 3 deletions agentidentity/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -122,15 +122,16 @@ 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
}
// 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 {
Expand Down
53 changes: 53 additions & 0 deletions agentidentity/preview_target.go
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions agentidentity/preview_target_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
Loading
Loading