Skip to content

Commit 0a3c5ec

Browse files
authored
fix: keep Browser visible with partial denies (#213)
1 parent 446db33 commit 0a3c5ec

2 files changed

Lines changed: 209 additions & 2 deletions

File tree

lib/console-policy-parser.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,54 @@ function matchesRequestedAction(policyActions: string[] | undefined, action: str
228228
return !!impliedActions && impliedActions.some((implied) => matchAction(policyActions, implied))
229229
}
230230

231+
function matchesDeniedAction(policyActions: string[] | undefined, action: string): boolean {
232+
if (IMPLIED_SCOPES[action]) {
233+
// A page scope aggregates independent backend capabilities. Denying one
234+
// capability must not hide the page; concrete capability checks still
235+
// enforce that deny.
236+
return matchAction(policyActions, action) || matchAction(policyActions, CONSOLE_SCOPES.CONSOLE_ADMIN)
237+
}
238+
239+
return matchesRequestedAction(policyActions, action)
240+
}
241+
242+
function getImpliedCapabilities(scope: string): string[] {
243+
const impliedActions = IMPLIED_SCOPES[scope] ?? []
244+
const concreteServices = new Set(
245+
impliedActions.filter((action) => !action.includes("*")).map((action) => normalizeAction(action).split(":")[0]),
246+
)
247+
248+
// Service wildcards are aliases when concrete actions for that service are
249+
// known, but remain capabilities for services represented only by a wildcard.
250+
return impliedActions.filter(
251+
(action) => !action.includes("*") || !concreteServices.has(normalizeAction(action).split(":")[0]),
252+
)
253+
}
254+
255+
function statementDeniesActionGlobally(statement: ConsoleStatement, action: string): boolean {
256+
if (statement.Effect !== "Deny") return false
257+
258+
const actionDenied =
259+
statement.NotAction && statement.NotAction.length > 0
260+
? !matchNotAction(statement.NotAction, action)
261+
: matchAction(statement.Action, action)
262+
263+
if (!actionDenied || isAdminAction(action)) return actionDenied
264+
if (statement.NotResource && statement.NotResource.length > 0) return false
265+
if (!statement.Resource || statement.Resource.length === 0) return true
266+
if (statement.Resource.includes("*")) return true
267+
268+
return normalizeAction(action).startsWith("s3:") && statement.Resource.includes("arn:aws:s3:::*")
269+
}
270+
271+
function deniesAllImpliedCapabilities(statements: ConsoleStatement[], scope: string): boolean {
272+
const capabilities = getImpliedCapabilities(scope)
273+
return (
274+
capabilities.length > 0 &&
275+
capabilities.every((action) => statements.some((statement) => statementDeniesActionGlobally(statement, action)))
276+
)
277+
}
278+
231279
/**
232280
* Check if an action is a console scope (starts with "console:" or is "consoleAdmin")
233281
*/
@@ -259,14 +307,14 @@ export function hasConsolePermission(
259307
}
260308

261309
// If Action is present (or empty array), deny applies to matching actions
262-
if (matchesRequestedAction(s.Action, action)) {
310+
if (matchesDeniedAction(s.Action, action)) {
263311
return matchStatementResource(s, resource, action)
264312
}
265313

266314
return false
267315
})
268316

269-
if (denied) return false
317+
if (denied || deniesAllImpliedCapabilities(statements, action)) return false
270318

271319
// Check Allow statements
272320
const allowed = statements.some((s) => {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
4+
import { CONSOLE_SCOPES } from "../../lib/console-permissions"
5+
import { hasConsolePermission, type ConsolePolicy } from "../../lib/console-policy-parser"
6+
7+
const maintainerPolicy: ConsolePolicy = {
8+
Version: "2012-10-17",
9+
Statement: [
10+
{
11+
Effect: "Allow",
12+
Action: ["s3:*"],
13+
Resource: ["arn:aws:s3:::*"],
14+
},
15+
{
16+
Effect: "Deny",
17+
Action: ["s3:DeleteObject"],
18+
Resource: ["arn:aws:s3:::*"],
19+
},
20+
],
21+
}
22+
23+
test("a denied browser capability does not hide the Browser page", () => {
24+
assert.equal(hasConsolePermission(maintainerPolicy, CONSOLE_SCOPES.VIEW_BROWSER), true)
25+
})
26+
27+
test("a denied browser capability remains unavailable", () => {
28+
assert.equal(hasConsolePermission(maintainerPolicy, "s3:DeleteObject", "arn:aws:s3:::test/object.txt"), false)
29+
})
30+
31+
test("an explicit Browser scope deny hides the Browser page", () => {
32+
const policy: ConsolePolicy = {
33+
...maintainerPolicy,
34+
Statement: [
35+
...maintainerPolicy.Statement,
36+
{
37+
Effect: "Deny",
38+
Action: [CONSOLE_SCOPES.VIEW_BROWSER],
39+
Resource: ["console"],
40+
},
41+
],
42+
}
43+
44+
assert.equal(hasConsolePermission(policy, CONSOLE_SCOPES.VIEW_BROWSER), false)
45+
})
46+
47+
test("a Console scope wildcard deny hides the Browser page", () => {
48+
const policy: ConsolePolicy = {
49+
...maintainerPolicy,
50+
Statement: [
51+
...maintainerPolicy.Statement,
52+
{
53+
Effect: "Deny",
54+
Action: ["console:*"],
55+
Resource: ["console"],
56+
},
57+
],
58+
}
59+
60+
assert.equal(hasConsolePermission(policy, CONSOLE_SCOPES.VIEW_BROWSER), false)
61+
})
62+
63+
test("a global S3 wildcard deny hides the Browser page", () => {
64+
const policy: ConsolePolicy = {
65+
Version: "2012-10-17",
66+
Statement: [
67+
{
68+
Effect: "Allow",
69+
Action: ["s3:*"],
70+
Resource: ["*"],
71+
},
72+
{
73+
Effect: "Deny",
74+
Action: ["s3:*"],
75+
Resource: ["arn:aws:s3:::*"],
76+
},
77+
],
78+
}
79+
80+
assert.equal(hasConsolePermission(policy, CONSOLE_SCOPES.VIEW_BROWSER), false)
81+
})
82+
83+
test("a bucket-scoped S3 wildcard deny does not hide the Browser page", () => {
84+
const policy: ConsolePolicy = {
85+
Version: "2012-10-17",
86+
Statement: [
87+
{
88+
Effect: "Allow",
89+
Action: ["s3:*"],
90+
Resource: ["arn:aws:s3:::*"],
91+
},
92+
{
93+
Effect: "Deny",
94+
Action: ["s3:*"],
95+
Resource: ["arn:aws:s3:::restricted/*"],
96+
},
97+
],
98+
}
99+
100+
assert.equal(hasConsolePermission(policy, CONSOLE_SCOPES.VIEW_BROWSER), true)
101+
})
102+
103+
test("an admin wildcard deny hides the Users page", () => {
104+
const policy: ConsolePolicy = {
105+
Version: "2012-10-17",
106+
Statement: [
107+
{
108+
Effect: "Allow",
109+
Action: ["admin:*"],
110+
},
111+
{
112+
Effect: "Deny",
113+
Action: ["admin:*"],
114+
},
115+
],
116+
}
117+
118+
assert.equal(hasConsolePermission(policy, CONSOLE_SCOPES.VIEW_USERS), false)
119+
})
120+
121+
test("multiple denies that cover all Users capabilities hide the page", () => {
122+
const policy: ConsolePolicy = {
123+
Version: "2012-10-17",
124+
Statement: [
125+
{
126+
Effect: "Allow",
127+
Action: ["admin:*"],
128+
},
129+
{
130+
Effect: "Deny",
131+
Action: ["admin:ListUsers", "admin:CreateUser", "admin:GetUser"],
132+
},
133+
{
134+
Effect: "Deny",
135+
Action: ["admin:EnableUser", "admin:DisableUser", "admin:DeleteUser"],
136+
},
137+
],
138+
}
139+
140+
assert.equal(hasConsolePermission(policy, CONSOLE_SCOPES.VIEW_USERS), false)
141+
})
142+
143+
test("a total deny for one service does not hide a mixed-service page", () => {
144+
const policy: ConsolePolicy = {
145+
Version: "2012-10-17",
146+
Statement: [
147+
{
148+
Effect: "Allow",
149+
Action: ["kms:*"],
150+
},
151+
{
152+
Effect: "Deny",
153+
Action: ["admin:*"],
154+
},
155+
],
156+
}
157+
158+
assert.equal(hasConsolePermission(policy, CONSOLE_SCOPES.VIEW_SSE_SETTINGS), true)
159+
})

0 commit comments

Comments
 (0)