Skip to content

Commit 94ef926

Browse files
Skryptclaude
andcommitted
Add Phase III OAuth ACL enforcement for DFS endpoint
Implement OAuth with ACL enforcement as specified by the Azurite ADLS Gen2 wiki Phase III ("OAuth: ACL works when user login with AAD account"). - OAuthLevel: Add ACL level (--oauth acl) alongside existing BASIC level. - ConfigurationBase: Accept "acl" as --oauth parameter value. - DfsContext: Add IDfsAuthenticatedIdentity interface with oid, upn, tid, appid fields; add identity field to IDfsContext. - DfsAuthenticationMiddleware: Extract identity claims (oid, upn, tid, appid) from Bearer JWT tokens when ACL mode is active; store in DFS context for downstream enforcement. - DfsAclEnforcer: New module implementing POSIX ACL evaluation: - Parses ACL strings ("user::rwx,user:oid:r-x,group::r-x,other::---") - Evaluates in POSIX order: owner -> named user -> group -> other - Applies mask entries to limit named user/group permissions - $superuser and unauthenticated requests bypass checks - Maps operations to required permissions (r/w/x) - PathHandler: Add enforceAcl() helper; check ACL before getProperties, read, delete, and update operations; accept OAuthLevel from factory. - DfsRequestListenerFactory: Pass OAuth level to PathHandler. - Tests: 19 unit tests for ACL enforcer covering parsing, bypass scenarios, owner/named-user/group/other permissions, mask application, and UPN matching. All passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb22f44 commit 94ef926

File tree

8 files changed

+515
-6
lines changed

8 files changed

+515
-6
lines changed

src/blob/DfsRequestListenerFactory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export default class DfsRequestListenerFactory implements IRequestListenerFactor
5757
const app = express().disable("x-powered-by");
5858

5959
const filesystemHandler = new FilesystemHandler(this.metadataStore, this.enableHierarchicalNamespace);
60-
const pathHandler = new PathHandler(this.metadataStore, this.extentStore);
60+
const pathHandler = new PathHandler(this.metadataStore, this.extentStore, this.oauth);
6161

6262
// Parse raw body for append operations
6363
app.use(express.raw({ type: "*/*", limit: "256mb" }));

src/blob/dfs/DfsAclEnforcer.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* ACL Enforcement for DFS (ADLS Gen2) operations.
3+
*
4+
* Phase III: When --oauth acl is enabled, checks the caller's identity
5+
* against POSIX ACL entries stored on each path before allowing operations.
6+
*
7+
* ACL format follows Azure ADLS Gen2:
8+
* "user::rwx,user:oid:r-x,group::r-x,other::---"
9+
*
10+
* Limitations (per wiki guidance):
11+
* - No AAD group membership resolution
12+
* - $superuser identity bypasses all ACL checks
13+
* - Emulator mode (no identity) bypasses all checks
14+
*/
15+
16+
import { IDfsAuthenticatedIdentity } from "./DfsContext";
17+
18+
/** Required permission for an operation */
19+
export type AclPermission = "r" | "w" | "x";
20+
21+
/**
22+
* Maps DFS operations to the minimum required permission.
23+
*/
24+
export function getRequiredPermission(
25+
operationDescription: string
26+
): AclPermission {
27+
switch (operationDescription) {
28+
case "read":
29+
case "getProperties":
30+
case "getAccessControl":
31+
case "listPaths":
32+
return "r";
33+
case "create":
34+
case "delete":
35+
case "update":
36+
case "setAccessControl":
37+
case "setProperties":
38+
case "rename":
39+
case "lease":
40+
return "w";
41+
case "listChildren":
42+
return "x";
43+
default:
44+
return "r";
45+
}
46+
}
47+
48+
/**
49+
* Parsed ACL entry.
50+
* Format: "type:entityId:permissions"
51+
* Examples: "user::rwx", "user:abc-123:r-x", "group::r--", "other::---"
52+
*/
53+
export interface AclEntry {
54+
type: "user" | "group" | "mask" | "other";
55+
entityId: string; // empty string for default user/group/other
56+
read: boolean;
57+
write: boolean;
58+
execute: boolean;
59+
}
60+
61+
/**
62+
* Parse an ACL string into structured entries.
63+
* ACL format: "user::rwx,user:abc:r-x,group::r-x,mask::rwx,other::---"
64+
*/
65+
export function parseAcl(aclString: string | undefined): AclEntry[] {
66+
if (!aclString) return [];
67+
68+
return aclString.split(",").filter(Boolean).map(entry => {
69+
const parts = entry.split(":");
70+
if (parts.length < 3) return null;
71+
72+
const type = parts[0] as AclEntry["type"];
73+
const entityId = parts[1];
74+
const perms = parts[2];
75+
76+
return {
77+
type,
78+
entityId,
79+
read: perms.charAt(0) === "r",
80+
write: perms.charAt(1) === "w",
81+
execute: perms.charAt(2) === "x"
82+
};
83+
}).filter((e): e is AclEntry => e !== null);
84+
}
85+
86+
/**
87+
* Check if an ACL entry grants the required permission.
88+
*/
89+
function entryHasPermission(entry: AclEntry, permission: AclPermission): boolean {
90+
switch (permission) {
91+
case "r": return entry.read;
92+
case "w": return entry.write;
93+
case "x": return entry.execute;
94+
}
95+
}
96+
97+
/**
98+
* Result of an ACL check.
99+
*/
100+
export interface AclCheckResult {
101+
allowed: boolean;
102+
reason: string;
103+
}
104+
105+
/**
106+
* Check whether the given identity is authorized for the required permission
107+
* based on the path's ACL metadata.
108+
*
109+
* Algorithm follows the POSIX ACL evaluation order:
110+
* 1. If owner matches identity → use owner permissions
111+
* 2. If a named user entry matches identity → use that entry (masked)
112+
* 3. If group matches → use group permissions (masked)
113+
* 4. Fall through to other permissions
114+
*
115+
* Special cases:
116+
* - $superuser always passes (emulator admin)
117+
* - No identity (unauthenticated) always passes (emulator dev mode)
118+
* - No ACL metadata → use default permissions (rwxr-x---)
119+
*/
120+
export function checkAcl(
121+
identity: IDfsAuthenticatedIdentity | undefined,
122+
owner: string | undefined,
123+
group: string | undefined,
124+
permissionsStr: string | undefined,
125+
aclStr: string | undefined,
126+
requiredPermission: AclPermission
127+
): AclCheckResult {
128+
// No identity = emulator/dev mode → bypass
129+
if (!identity || (!identity.oid && !identity.upn)) {
130+
return { allowed: true, reason: "No authenticated identity — emulator mode bypass" };
131+
}
132+
133+
// $superuser bypasses all ACL checks
134+
const effectiveOwner = owner || "$superuser";
135+
if (effectiveOwner === "$superuser") {
136+
return { allowed: true, reason: "$superuser bypasses ACL checks" };
137+
}
138+
139+
const callerId = identity.oid || identity.upn || "";
140+
141+
// Check if caller is the owner
142+
if (callerId === effectiveOwner) {
143+
// Use owner permissions from the permissions string (chars 0-2)
144+
const perms = permissionsStr || "rwxr-x---";
145+
const ownerPerms: AclEntry = {
146+
type: "user",
147+
entityId: "",
148+
read: perms.charAt(0) === "r",
149+
write: perms.charAt(1) === "w",
150+
execute: perms.charAt(2) === "x"
151+
};
152+
if (entryHasPermission(ownerPerms, requiredPermission)) {
153+
return { allowed: true, reason: "Owner permission granted" };
154+
}
155+
return { allowed: false, reason: "Owner does not have required permission" };
156+
}
157+
158+
// Parse ACL entries for named user/group matching
159+
const aclEntries = parseAcl(aclStr);
160+
161+
// Find mask entry (used to limit named user and group permissions)
162+
const maskEntry = aclEntries.find(e => e.type === "mask" && e.entityId === "");
163+
164+
// Check named user entries
165+
const namedUser = aclEntries.find(
166+
e => e.type === "user" && e.entityId !== "" && e.entityId === callerId
167+
);
168+
if (namedUser) {
169+
const effective = maskEntry
170+
? entryHasPermission(namedUser, requiredPermission) && entryHasPermission(maskEntry, requiredPermission)
171+
: entryHasPermission(namedUser, requiredPermission);
172+
if (effective) {
173+
return { allowed: true, reason: `Named user ACL entry matched (${callerId})` };
174+
}
175+
return { allowed: false, reason: `Named user ACL entry matched but lacks permission` };
176+
}
177+
178+
// Check group (we can't resolve AD group membership per wiki constraints,
179+
// so we only check the owning group if the caller matches it)
180+
const effectiveGroup = group || "$superuser";
181+
if (callerId === effectiveGroup) {
182+
const perms = permissionsStr || "rwxr-x---";
183+
const groupPerms: AclEntry = {
184+
type: "group",
185+
entityId: "",
186+
read: perms.charAt(3) === "r",
187+
write: perms.charAt(4) === "w",
188+
execute: perms.charAt(5) === "x"
189+
};
190+
const effective = maskEntry
191+
? entryHasPermission(groupPerms, requiredPermission) && entryHasPermission(maskEntry, requiredPermission)
192+
: entryHasPermission(groupPerms, requiredPermission);
193+
if (effective) {
194+
return { allowed: true, reason: "Group permission granted" };
195+
}
196+
return { allowed: false, reason: "Group does not have required permission" };
197+
}
198+
199+
// Fall through to "other" permissions (chars 6-8)
200+
const perms = permissionsStr || "rwxr-x---";
201+
const otherPerms: AclEntry = {
202+
type: "other",
203+
entityId: "",
204+
read: perms.charAt(6) === "r",
205+
write: perms.charAt(7) === "w",
206+
execute: perms.charAt(8) === "x"
207+
};
208+
if (entryHasPermission(otherPerms, requiredPermission)) {
209+
return { allowed: true, reason: "Other permission granted" };
210+
}
211+
212+
return { allowed: false, reason: "Insufficient ACL permissions" };
213+
}

src/blob/dfs/DfsAuthenticationMiddleware.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { decode } from "jsonwebtoken";
12
import { NextFunction, Request, RequestHandler, Response } from "express";
23

34
import IAccountDataStore from "../../common/IAccountDataStore";
@@ -12,10 +13,11 @@ import ExpressRequestAdapter from "../generated/ExpressRequestAdapter";
1213

1314
import Operation from "../generated/artifacts/operation";
1415
import IBlobMetadataStore from "../persistence/IBlobMetadataStore";
15-
import { getDfsContext } from "./DfsContext";
16+
import { getDfsContext, IDfsAuthenticatedIdentity } from "./DfsContext";
1617
import { DfsOperation } from "./DfsOperation";
1718
import { sendDfsError } from "./DfsErrorFactory";
1819
import { OAuthLevel } from "../../common/models";
20+
import { BEARER_TOKEN_PREFIX } from "../../common/utils/constants";
1921

2022
const DEFAULT_CONTEXT_PATH = "dfs_blob_context";
2123

@@ -55,6 +57,32 @@ function mapDfsOperationToBlobOperation(op?: DfsOperation): Operation {
5557
}
5658
}
5759

60+
/**
61+
* Extracts identity claims from a Bearer JWT token.
62+
* Returns undefined if the token is not a Bearer token or can't be decoded.
63+
*/
64+
function extractIdentityFromRequest(req: Request): IDfsAuthenticatedIdentity | undefined {
65+
const authHeader = req.header("authorization");
66+
if (!authHeader || !authHeader.startsWith(BEARER_TOKEN_PREFIX)) {
67+
return undefined;
68+
}
69+
70+
const token = authHeader.substring(BEARER_TOKEN_PREFIX.length + 1);
71+
try {
72+
const decoded = decode(token) as { [key: string]: any } | null;
73+
if (!decoded) return undefined;
74+
75+
return {
76+
oid: decoded.oid as string | undefined,
77+
upn: decoded.upn as string | undefined,
78+
tid: decoded.tid as string | undefined,
79+
appid: decoded.appid as string | undefined
80+
};
81+
} catch {
82+
return undefined;
83+
}
84+
}
85+
5886
export default function createDfsAuthenticationMiddleware(
5987
accountDataStore: IAccountDataStore,
6088
metadataStore: IBlobMetadataStore,
@@ -118,6 +146,12 @@ export default function createDfsAuthenticationMiddleware(
118146
return;
119147
}
120148

149+
// When ACL mode is enabled, extract identity from bearer token
150+
// so ACL enforcement can check permissions downstream
151+
if (oauth === OAuthLevel.ACL) {
152+
dfsCtx.identity = extractIdentityFromRequest(req);
153+
}
154+
121155
next();
122156
} catch (error: any) {
123157
if (error.statusCode) {

src/blob/dfs/DfsContext.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ import { SECONDARY_SUFFIX, HeaderConstants, ValidAPIVersions, VERSION } from "..
77
import { checkApiVersion } from "../utils/utils";
88
import { DfsOperation } from "./DfsOperation";
99

10+
/**
11+
* Identity extracted from an OAuth bearer token.
12+
* Used for ACL enforcement in Phase III (--oauth acl).
13+
*/
14+
export interface IDfsAuthenticatedIdentity {
15+
/** Azure AD object ID (oid claim) */
16+
oid?: string;
17+
/** User principal name (upn claim) */
18+
upn?: string;
19+
/** Tenant ID (tid claim) */
20+
tid?: string;
21+
/** Application ID (appid claim) */
22+
appid?: string;
23+
}
24+
1025
export interface IDfsContext {
1126
requestId: string;
1227
startTime: Date;
@@ -16,6 +31,8 @@ export interface IDfsContext {
1631
isSecondary?: boolean;
1732
operation?: DfsOperation;
1833
authenticationPath?: string;
34+
/** Authenticated identity from OAuth token — populated when --oauth acl is enabled */
35+
identity?: IDfsAuthenticatedIdentity;
1936
}
2037

2138
const DFS_CONTEXT_KEY = "dfsContext";

0 commit comments

Comments
 (0)