From cdc616e098c8c18e7616aa16bf4e64e262e72949 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Mon, 16 Mar 2026 12:36:27 -0400 Subject: [PATCH] Permission Ceiling for Agents (#12932) (#13041) --- vault/request_handling.go | 78 ++++++++++++++++++++++++++-- vault/request_handling_ce.go | 4 ++ vault/request_handling_test.go | 93 ++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/vault/request_handling.go b/vault/request_handling.go index f1adf8987a..6736b24f52 100644 --- a/vault/request_handling.go +++ b/vault/request_handling.go @@ -350,11 +350,17 @@ func (c *Core) fetchACLTokenEntryAndEntity(ctx context.Context, req *logical.Req if secondEntity != nil { c.logger.Debug("building separate ACL for second entity", "entity_id", secondEntity.ID) secondEntityPolicyNames = make(map[string][]string) - _, secondEntityIdentityPolicies, err := c.fetchEntityAndDerivedPolicies(ctx, tokenNS, secondEntity.ID, false) + secondEntityIdentityPolicies, err := c.fetchCeilingPolicies(ctx, secondEntity) + if err != nil { + return nil, nil, nil, nil, err + } + allowOnly, err := c.allPoliciesAllowOnly(ctx, secondEntityIdentityPolicies) if err != nil { - c.logger.Error("failed to fetch second entity policies", "error", err) return nil, nil, nil, nil, ErrInternalError } + if !allowOnly { + return nil, nil, nil, nil, logical.ErrPermissionDenied + } // Store second entity policies separately - do NOT merge with primary entity's policies for nsID, nsPolicies := range secondEntityIdentityPolicies { secondEntityPolicyNames[nsID] = policyutil.SanitizePolicies(nsPolicies, false) @@ -401,7 +407,7 @@ func (c *Core) fetchACLTokenEntryAndEntity(ctx context.Context, req *logical.Req return nil, nil, nil, nil, ErrInternalError } - if secondEntity != nil && len(secondEntityPolicyNames) > 0 { + if secondEntity != nil { newAcl, err := c.performSecondaryEntityTokenChecks(tokenCtx, acl, secondEntity, secondEntityPolicyNames) if err != nil { return nil, nil, nil, nil, err @@ -2980,3 +2986,69 @@ func (c *Core) checkSSCTokenInternal(ctx context.Context, token string, isPerfSt // status code. return "", logical.ErrMissingRequiredState } + +// allPoliciesAllowOnly is a helper function that checks if all policies in +// a given set have only "allow" capabilities, and not "deny" or "sudo". +// +// Example of allow-only policy: +// +// path "secret/data/team/public/*" { +// capabilities = ["read"] +// } +// +// Example of a policy that is not allow-only: +// +// path "secret/data/team/*" { +// capabilities = ["read"] +// } +// +// path "secret/data/team/private/*" { +// capabilities = ["deny"] +// } +func (c *Core) allPoliciesAllowOnly(ctx context.Context, policyNamesByNamespace map[string][]string) (bool, error) { + for nsID, policyNames := range policyNamesByNamespace { + policyNS, err := NamespaceByID(ctx, nsID, c) + if err != nil { + return false, err + } + if policyNS == nil { + return false, namespace.ErrNoNamespace + } + + policyCtx := namespace.ContextWithNamespace(ctx, policyNS) + for _, policyName := range policyNames { + policy, err := c.policyStore.GetPolicy(policyCtx, policyName, PolicyTypeACL) + if err != nil { + return false, err + } + if policy == nil { + return false, fmt.Errorf("policy %q not found in namespace %q", policyName, policyNS.Path) + } + if !policyIsAllowOnly(policy) { + return false, nil + } + } + } + + return true, nil +} + +// policyIsAllowOnly is a helper function that checks if a policy has only "allow" capabilities, and not "deny" or "sudo". +func policyIsAllowOnly(policy *Policy) bool { + if policy == nil || policy.Name == "root" { + return false + } + + for _, pathRules := range policy.Paths { + if pathRules == nil || pathRules.Permissions == nil { + continue + } + + capabilities := pathRules.Permissions.CapabilitiesBitmap + if capabilities&DenyCapabilityInt != 0 || capabilities&SudoCapabilityInt != 0 { + return false + } + } + + return true +} diff --git a/vault/request_handling_ce.go b/vault/request_handling_ce.go index 64e0953aea..7cb5b254ab 100644 --- a/vault/request_handling_ce.go +++ b/vault/request_handling_ce.go @@ -40,3 +40,7 @@ func getEnterpriseTokenAuthorizationDetails(_ map[string]interface{}) []logical. func (c *Core) performSecondaryEntityTokenChecks(_ context.Context, _ *ACL, _ *identity.Entity, _ map[string][]string) (*ACL, error) { return nil, errors.New("not implemented") } + +func (c *Core) fetchCeilingPolicies(ctx context.Context, entity *identity.Entity) (map[string][]string, error) { + return nil, errors.New("not implemented") +} diff --git a/vault/request_handling_test.go b/vault/request_handling_test.go index e563121ed3..839e91a152 100644 --- a/vault/request_handling_test.go +++ b/vault/request_handling_test.go @@ -643,6 +643,99 @@ func TestRequestHandling_fetchACLTokenEntryAndEntity_NilRequest(t *testing.T) { require.Equal(t, ErrInternalError, err) } +// Test_allPoliciesAllowOnly tests a helper function that checks if all policies in +// a given set have only "allow" capabilities, and not "deny" or "sudo" +func Test_allPoliciesAllowOnly(t *testing.T) { + t.Parallel() + + c, _, _ := TestCoreUnsealed(t) + ctx := namespace.RootContext(context.Background()) + + allowPolicy, err := ParseACLPolicy(namespace.RootNamespace, ` +path "secret/data/*" { + capabilities = ["read", "list"] +} +`) + require.NoError(t, err) + allowPolicy.Name = "allow-only" + require.NoError(t, c.policyStore.SetPolicy(ctx, allowPolicy)) + + denyPolicy, err := ParseACLPolicy(namespace.RootNamespace, ` +path "secret/data/*" { + capabilities = ["deny"] +} +`) + require.NoError(t, err) + denyPolicy.Name = "deny-policy" + require.NoError(t, c.policyStore.SetPolicy(ctx, denyPolicy)) + + sudoPolicy, err := ParseACLPolicy(namespace.RootNamespace, ` +path "secret/data/*" { + capabilities = ["read", "sudo"] +} +`) + require.NoError(t, err) + sudoPolicy.Name = "sudo-policy" + require.NoError(t, c.policyStore.SetPolicy(ctx, sudoPolicy)) + + tests := map[string]struct { + policyNamesByNamespace map[string][]string + expected bool + wantErr string + }{ + "all allow only": { + policyNamesByNamespace: map[string][]string{ + namespace.RootNamespaceID: {"allow-only"}, + }, + expected: true, + }, + "deny policy": { + policyNamesByNamespace: map[string][]string{ + namespace.RootNamespaceID: {"deny-policy"}, + }, + expected: false, + }, + "sudo policy": { + policyNamesByNamespace: map[string][]string{ + namespace.RootNamespaceID: {"sudo-policy"}, + }, + expected: false, + }, + "root policy": { + policyNamesByNamespace: map[string][]string{ + namespace.RootNamespaceID: {"root"}, + }, + expected: false, + }, + "missing policy": { + policyNamesByNamespace: map[string][]string{ + namespace.RootNamespaceID: {"missing-policy"}, + }, + expected: false, + wantErr: "policy \"missing-policy\" not found", + }, + "missing namespace": { + policyNamesByNamespace: map[string][]string{ + "missing-namespace": {"allow-only"}, + }, + expected: false, + wantErr: namespace.ErrNoNamespace.Error(), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual, err := c.allPoliciesAllowOnly(ctx, tc.policyNamesByNamespace) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.expected, actual) + }) + } +} + // TestAuth_AuthorizationDetails_CopiedFromRequest verifies that logical.Auth.AuthorizationDetails // matches the authorization details already carried on the request. func TestAuth_AuthorizationDetails_CopiedFromRequest(t *testing.T) {