Skip to content

Commit 643b6fa

Browse files
committed
feat: auth-include permissions in user object
1 parent 6839304 commit 643b6fa

26 files changed

+233
-246
lines changed

.oxlintrc.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
"arrow-body-style": "allow",
1818
"id-length": ["warn", { "exceptions": ["z"] }],
1919
"func-style": ["allow"],
20-
"max-lines-per-function": ["warn", { "max": 50, "skipBlankLines": true, "skipComments": true }],
20+
"max-lines-per-function": [
21+
"warn",
22+
{ "max": 50, "skipBlankLines": true, "skipComments": true }
23+
],
2124
"import/consistent-type-specifier-style": "allow",
2225
"import/exports-last": "allow",
2326
"import/extensions": ["deny", "always"],
@@ -44,5 +47,13 @@
4447
"switch-exhaustiveness-check": "warn",
4548
"strict-boolean-expressions": "allow",
4649
"typescript/strict-boolean-expressions": "allow"
47-
}
50+
},
51+
"overrides": [
52+
{
53+
"files": ["**/*.test.ts", "**/*.spec.ts", "**/mocks/**"],
54+
"rules": {
55+
"typescript/no-confusing-void-expression": "allow"
56+
}
57+
}
58+
]
4859
}

packages/azure/src/easy-auth.ts

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
// oxlint-disable class-methods-use-this
2-
// oxlint-disable require-await
3-
4-
import type {
5-
AuthAdapter,
6-
AuthAdapterAuthorise,
7-
AuthAdapterOptions,
8-
StoryBookerUser,
9-
} from "@storybooker/core/adapter";
2+
3+
import {
4+
StoryBookerPermissionsList,
5+
StoryBookerPermissionsAllEnabled,
6+
type AuthAdapter,
7+
type AuthAdapterOptions,
8+
type StoryBookerPermissionAction,
9+
type StoryBookerPermissionResource,
10+
type StoryBookerPermissionWithKey,
11+
type StoryBookerUser,
12+
} from "@storybooker/core/adapter/auth";
1013
import { Buffer } from "node:buffer";
1114

1215
export type {
13-
AuthAdapterAuthorise,
1416
StoryBookerPermission,
1517
StoryBookerPermissionAction,
1618
StoryBookerPermissionKey,
1719
StoryBookerPermissionResource,
1820
StoryBookerPermissionWithKey,
19-
} from "@storybooker/core/adapter";
21+
} from "@storybooker/core/adapter/auth";
2022

2123
export interface AzureEasyAuthClientPrincipal {
2224
claims: { typ: string; val: string }[];
@@ -31,23 +33,24 @@ export interface AzureEasyAuthUser extends StoryBookerUser {
3133
clientPrincipal?: AzureEasyAuthClientPrincipal;
3234
}
3335

36+
export type AuthAdapterAuthorise<AuthUser extends StoryBookerUser = StoryBookerUser> = (
37+
permission: StoryBookerPermissionWithKey,
38+
user: Omit<AuthUser, "permissions">,
39+
) => boolean;
40+
3441
/**
3542
* Modify the final user details object created from EasyAuth Client Principal.
3643
*/
37-
export type ModifyUserDetails = (
38-
user: AzureEasyAuthUser,
44+
export type ModifyUserDetails = <User extends Omit<AzureEasyAuthUser, "permissions">>(
45+
user: User,
3946
options: AuthAdapterOptions,
40-
) => AzureEasyAuthUser | Promise<AzureEasyAuthUser>;
47+
) => User | Promise<User>;
4148

42-
const DEFAULT_AUTHORISE: AuthAdapterAuthorise<AzureEasyAuthUser> = ({ permission, user }) => {
49+
const DEFAULT_AUTHORISE: AuthAdapterAuthorise<AzureEasyAuthUser> = (permission, user) => {
4350
if (!user) {
4451
return false;
4552
}
4653

47-
if (user.type === "application") {
48-
return true;
49-
}
50-
5154
if (permission.action === "read") {
5255
return true;
5356
}
@@ -59,10 +62,15 @@ const DEFAULT_MODIFY_USER: ModifyUserDetails = (user) => user;
5962

6063
/**
6164
* StoryBooker Auth adapter for Azure EasyAuth.
65+
*
66+
* @example
67+
* ```ts
68+
* const auth = new AzureEasyAuthService();
69+
* ```
6270
*/
6371
export class AzureEasyAuthService implements AuthAdapter<AzureEasyAuthUser> {
64-
authorise: AuthAdapter<AzureEasyAuthUser>["authorise"];
65-
modifyUserDetails: ModifyUserDetails;
72+
#authorise: AuthAdapterAuthorise<AzureEasyAuthUser>;
73+
#modifyUserDetails: ModifyUserDetails;
6674

6775
metadata: AuthAdapter["metadata"] = { name: "Azure Easy Auth" };
6876

@@ -76,8 +84,8 @@ export class AzureEasyAuthService implements AuthAdapter<AzureEasyAuthUser> {
7684
*/
7785
modifyUserDetails?: ModifyUserDetails;
7886
}) {
79-
this.authorise = options?.authorise ?? DEFAULT_AUTHORISE;
80-
this.modifyUserDetails = options?.modifyUserDetails ?? DEFAULT_MODIFY_USER;
87+
this.#authorise = options?.authorise ?? DEFAULT_AUTHORISE;
88+
this.#modifyUserDetails = options?.modifyUserDetails ?? DEFAULT_MODIFY_USER;
8189
}
8290

8391
getUserDetails: AuthAdapter<AzureEasyAuthUser>["getUserDetails"] = async (options) => {
@@ -100,10 +108,11 @@ export class AzureEasyAuthService implements AuthAdapter<AzureEasyAuthUser> {
100108
clientPrincipal,
101109
displayName: "App",
102110
id: azpToken,
111+
permissions: StoryBookerPermissionsAllEnabled,
103112
roles: null,
104113
type: "application",
105114
};
106-
return this.modifyUserDetails(user, options);
115+
return user;
107116
}
108117

109118
const name = claims.find((claim) => claim.typ === "name")?.val;
@@ -112,18 +121,22 @@ export class AzureEasyAuthService implements AuthAdapter<AzureEasyAuthUser> {
112121
.filter((claim) => claim.typ === clientPrincipal.role_typ || claim.typ === "roles")
113122
.map((claim) => claim.val);
114123

115-
const user: AzureEasyAuthUser = {
124+
const userWithoutPermissions: Omit<AzureEasyAuthUser, "permissions"> = {
116125
clientPrincipal,
117126
displayName: name ?? "",
118127
id: email ?? "",
119128
roles,
120129
title: roles.join(", "),
121130
type: "user",
122131
};
123-
return this.modifyUserDetails(user, options);
132+
133+
return {
134+
...(await this.#modifyUserDetails(userWithoutPermissions, options)),
135+
permissions: authoriseUserPermissions(this.#authorise, userWithoutPermissions),
136+
};
124137
};
125138

126-
login: AuthAdapter<AzureEasyAuthUser>["login"] = async ({ request }) => {
139+
login: AuthAdapter<AzureEasyAuthUser>["login"] = ({ request }) => {
127140
const url = new URL("/.auth/login", request.url);
128141

129142
return new Response(null, {
@@ -132,7 +145,7 @@ export class AzureEasyAuthService implements AuthAdapter<AzureEasyAuthUser> {
132145
});
133146
};
134147

135-
logout: AuthAdapter<AzureEasyAuthUser>["logout"] = async (_user, { request }) => {
148+
logout: AuthAdapter<AzureEasyAuthUser>["logout"] = (_user, { request }) => {
136149
const url = new URL("/.auth/logout", request.url);
137150

138151
return new Response(null, {
@@ -141,3 +154,21 @@ export class AzureEasyAuthService implements AuthAdapter<AzureEasyAuthUser> {
141154
});
142155
};
143156
}
157+
158+
function authoriseUserPermissions(
159+
authorise: AuthAdapterAuthorise<AzureEasyAuthUser>,
160+
user: Omit<AzureEasyAuthUser, "permissions">,
161+
): AzureEasyAuthUser["permissions"] {
162+
const permissions: AzureEasyAuthUser["permissions"] = {};
163+
164+
for (const key of StoryBookerPermissionsList) {
165+
const [resource, action] = key.split(":") as [
166+
StoryBookerPermissionResource,
167+
StoryBookerPermissionAction,
168+
];
169+
const permission: StoryBookerPermissionWithKey = { action, key, resource };
170+
permissions[key] = authorise(permission, user);
171+
}
172+
173+
return permissions;
174+
}

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@
112112
"source": "./src/utils/index.ts",
113113
"default": "./dist/utils.mjs"
114114
},
115-
"./package.json": "./package.json"
115+
"./package.json": "./package.json",
116+
"./openapi.json": "./openapi.json"
116117
},
117118
"publishConfig": {
118119
"exports": {

packages/core/src/adapters/auth.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,7 @@ export interface AuthAdapter<AuthUser extends StoryBookerUser = StoryBookerUser>
2222
init?: (options: Omit<AuthAdapterOptions, "request">) => Promise<void>;
2323

2424
/**
25-
* This callback is called before every protected route and determines if user
26-
* has access to the route. It receives a permission object.
27-
*
28-
* @returns
29-
* - Respond with `true` to allow user to proceed.
30-
* - Respond with `false` to block user.
31-
* - Respond with `Response` to return custom response
32-
*/
33-
authorise: AuthAdapterAuthorise<AuthUser>;
34-
35-
/**
36-
* Get details about the user based on incoming request.
25+
* Get details about the user and permissions based on incoming request.
3726
*
3827
* @param options Common options like abortSignal.
3928
*
@@ -85,7 +74,7 @@ export type AuthAdapterAuthorise<AuthUser extends StoryBookerUser = StoryBookerU
8574
/** Type of permission to check */
8675
export interface StoryBookerPermission {
8776
action: StoryBookerPermissionAction;
88-
projectId: string | undefined;
77+
projectId?: string;
8978
resource: StoryBookerPermissionResource;
9079
}
9180
/** Permission object with key */
@@ -94,7 +83,7 @@ export type StoryBookerPermissionWithKey = StoryBookerPermission & {
9483
};
9584
/** Permission in a string format */
9685
export type StoryBookerPermissionKey =
97-
`${StoryBookerPermissionResource}:${StoryBookerPermissionAction}:${string}`;
86+
`${StoryBookerPermissionResource}:${StoryBookerPermissionAction}`;
9887
/** Type of possible resources to check permissions for */
9988
export type StoryBookerPermissionResource = "project" | "build" | "tag";
10089
/** Type of possible actions to check permissions for */
@@ -112,6 +101,8 @@ export interface StoryBookerUser {
112101
imageUrl?: string;
113102
/** Title or Team-name of the User shown in UI. */
114103
title?: string;
104+
/** Permissions assigned to the user. Missing permissions are considered false. */
105+
permissions: Partial<Record<StoryBookerPermissionKey, boolean>>;
115106
}
116107

117108
/** Common Auth adapter options. */
@@ -123,3 +114,22 @@ export interface AuthAdapterOptions {
123114
/** Logger */
124115
logger: LoggerAdapter;
125116
}
117+
118+
export const StoryBookerPermissionsAllEnabled = {
119+
"build:create": true,
120+
"build:delete": true,
121+
"project:update": true,
122+
"build:read": true,
123+
"tag:delete": true,
124+
"build:update": true,
125+
"tag:create": true,
126+
"project:read": true,
127+
"project:create": true,
128+
"tag:read": true,
129+
"project:delete": true,
130+
"tag:update": true,
131+
} satisfies Record<StoryBookerPermissionKey, true>;
132+
133+
export const StoryBookerPermissionsList = Object.keys(
134+
StoryBookerPermissionsAllEnabled,
135+
) as StoryBookerPermissionKey[];

packages/core/src/handlers/handle-serve-storybook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function handleServeStoryBook({
1919
}): Promise<Response> {
2020
const { abortSignal, logger, storage } = getStore();
2121
const storageFilepath = path.posix.join(buildId, filepath);
22-
await authenticateOrThrow({ action: "read", projectId, resource: "build" });
22+
authenticateOrThrow({ action: "read", projectId, resource: "build" });
2323

2424
try {
2525
const { content, mimeType } = await storage.downloadFile(
Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,51 @@
11
// oxlint-disable require-await
2-
// oxlint-disable sort-keys
32

4-
import type { AuthAdapter, StoryBookerUser } from "../adapters/auth.ts";
3+
import {
4+
StoryBookerPermissionsAllEnabled,
5+
type AuthAdapter,
6+
type StoryBookerUser,
7+
} from "../adapters/auth.ts";
58

69
export const mockUser: StoryBookerUser = {
710
displayName: "Test User",
811
id: "test-user-id",
912
imageUrl: "https://example.com/avatar.png",
1013
title: "Tester",
14+
permissions: StoryBookerPermissionsAllEnabled,
1115
};
1216

13-
export const mockAuthService: AuthAdapter = {
14-
metadata: { name: "MockAuthService" },
17+
export function mockAuthService(
18+
permissions: Partial<StoryBookerUser["permissions"]> = {},
19+
): AuthAdapter {
20+
const user = {
21+
...mockUser,
22+
permissions: {
23+
...mockUser.permissions,
24+
...permissions,
25+
},
26+
};
1527

16-
init: async (_options) => {
17-
// Mock init logic if needed
18-
},
19-
authorise: async () => {
20-
// Allow all permissions for testing
21-
return true;
22-
},
23-
getUserDetails: async (_options) => {
24-
// Always return the mock user
25-
return mockUser;
26-
},
27-
login: async (_options) => {
28-
// Return a mock Response
29-
return new Response("Logged in", { status: 200 });
30-
},
31-
logout: async (_user, _options) => {
32-
// Return a mock Response
33-
return new Response("Logged out", { status: 200 });
34-
},
35-
renderAccountDetails: async (_user, _options) => {
36-
// Return mock HTML
37-
return "<div>Mock Account Details</div>";
38-
},
39-
};
28+
return {
29+
metadata: { name: "MockAuthService" },
30+
31+
init: async (_options): Promise<void> => {
32+
// Mock init logic if needed
33+
},
34+
getUserDetails: async (_options) => {
35+
// Always return the mock user
36+
return user;
37+
},
38+
login: async (_options) => {
39+
// Return a mock Response
40+
return new Response("Logged in", { status: 200 });
41+
},
42+
logout: async (_user, _options) => {
43+
// Return a mock Response
44+
return new Response("Logged out", { status: 200 });
45+
},
46+
renderAccountDetails: async (_user, _options) => {
47+
// Return mock HTML
48+
return "<div>Mock Account Details</div>";
49+
},
50+
};
51+
}

packages/core/src/mocks/mock-store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { mockAuthService, mockUser } from "./mock-auth-service";
88

99
export const mockStore: Store = {
1010
abortSignal: undefined,
11-
auth: mockAuthService,
11+
auth: mockAuthService(),
1212
headers: new SuperHeaders(),
1313
logger: {
1414
metadata: { name: "Mock Logger" },

packages/core/src/models/builds-model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,8 @@ export class BuildsModel extends Model<BuildType> {
237237
};
238238
};
239239

240-
async checkAuth(action: StoryBookerPermissionAction): Promise<boolean> {
241-
return await checkAuthorisation({
240+
checkAuth(action: StoryBookerPermissionAction): boolean {
241+
return checkAuthorisation({
242242
action,
243243
projectId: this.projectId,
244244
resource: "build",

packages/core/src/models/projects-model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ export class ProjectsModel extends Model<ProjectType> {
156156
await this.storage.deleteContainer(generateStorageContainerId(id), this.storageOptions);
157157
}
158158

159-
async checkAuth(action: StoryBookerPermissionAction, id?: string): Promise<boolean> {
160-
return await checkAuthorisation({
159+
checkAuth(action: StoryBookerPermissionAction, id?: string): boolean {
160+
return checkAuthorisation({
161161
action,
162162
projectId: id ?? (this.projectId || undefined),
163163
resource: "project",

0 commit comments

Comments
 (0)