Skip to content

Commit 986bce7

Browse files
Permission & Access Management with Better UI Warnings
1 parent db1a85b commit 986bce7

File tree

6 files changed

+729
-54
lines changed

6 files changed

+729
-54
lines changed

server/routers/external.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,13 @@ authenticated.post(
477477
resource.moveResourceToOrg
478478
);
479479

480+
authenticated.get(
481+
`/resource/:resourceId/move-impact`,
482+
verifyResourceAccess,
483+
verifyUserHasAction(ActionsEnum.updateResource),
484+
resource.getMoveImpact
485+
);
486+
480487

481488
authenticated.post(
482489
`/resource/:resourceId/access-token`,

server/routers/integration.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,13 @@ authenticated.post(
355355
resource.moveResourceToOrg
356356
);
357357

358+
authenticated.get(
359+
`/resource/:resourceId/move-impact`,
360+
verifyApiKeyResourceAccess,
361+
verifyApiKeyHasAction(ActionsEnum.updateResource),
362+
resource.getMoveImpact
363+
);
364+
358365

359366
authenticated.post(
360367
`/resource/:resourceId/access-token`,
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { Request, Response, NextFunction } from "express";
2+
import { z } from "zod";
3+
import { db } from "@server/db";
4+
import {
5+
resources,
6+
orgs,
7+
userOrgs,
8+
userResources,
9+
roleResources,
10+
roles,
11+
users
12+
} from "@server/db";
13+
import { eq, and } from "drizzle-orm";
14+
import response from "@server/lib/response";
15+
import HttpCode from "@server/types/HttpCode";
16+
import createHttpError from "http-errors";
17+
import logger from "@server/logger";
18+
import { fromError } from "zod-validation-error";
19+
import { registry, OpenAPITags } from "@server/openApi";
20+
21+
const getMoveImpactParamsSchema = z.object({
22+
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
23+
});
24+
25+
const getMoveImpactQuerySchema = z.object({
26+
targetOrgId: z.string().min(1)
27+
});
28+
29+
registry.registerPath({
30+
method: "get",
31+
path: "/resource/{resourceId}/move-impact",
32+
description: "Get the impact analysis of moving a resource to a different org",
33+
tags: [OpenAPITags.Resource],
34+
request: {
35+
params: getMoveImpactParamsSchema,
36+
query: getMoveImpactQuerySchema
37+
},
38+
responses: {
39+
200: {
40+
description: "Move impact analysis",
41+
content: {
42+
"application/json": {
43+
schema: {
44+
type: "object",
45+
properties: {
46+
data: {
47+
type: "object",
48+
properties: {
49+
resourceId: { type: "number" },
50+
resourceName: { type: "string" },
51+
currentOrgId: { type: "string" },
52+
currentOrgName: { type: "string" },
53+
targetOrgId: { type: "string" },
54+
targetOrgName: { type: "string" },
55+
impact: {
56+
type: "object",
57+
properties: {
58+
rolePermissions: {
59+
type: "object",
60+
properties: {
61+
count: { type: "number" },
62+
details: {
63+
type: "array",
64+
items: {
65+
type: "object",
66+
properties: {
67+
roleId: { type: "number" },
68+
roleName: { type: "string" }
69+
}
70+
}
71+
}
72+
}
73+
},
74+
userPermissions: {
75+
type: "object",
76+
properties: {
77+
count: { type: "number" },
78+
details: {
79+
type: "array",
80+
items: {
81+
type: "object",
82+
properties: {
83+
userId: { type: "string" },
84+
username: { type: "string" },
85+
email: { type: "string" }
86+
}
87+
}
88+
}
89+
}
90+
},
91+
totalImpactedPermissions: { type: "number" },
92+
authenticationPreserved: { type: "boolean" },
93+
movingUserRetainsAccess: { type: "boolean" }
94+
}
95+
}
96+
}
97+
}
98+
}
99+
}
100+
}
101+
}
102+
}
103+
}
104+
});
105+
106+
export async function getMoveImpact(
107+
req: Request,
108+
res: Response,
109+
next: NextFunction
110+
) {
111+
try {
112+
const parsedParams = getMoveImpactParamsSchema.safeParse(req.params);
113+
if (!parsedParams.success) {
114+
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()));
115+
}
116+
117+
const parsedQuery = getMoveImpactQuerySchema.safeParse(req.query);
118+
if (!parsedQuery.success) {
119+
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString()));
120+
}
121+
122+
const { resourceId } = parsedParams.data;
123+
const { targetOrgId } = parsedQuery.data;
124+
const user = req.user;
125+
126+
if (!user) {
127+
return next(createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated"));
128+
}
129+
130+
const [resource] = await db
131+
.select()
132+
.from(resources)
133+
.where(eq(resources.resourceId, resourceId))
134+
.limit(1);
135+
136+
if (!resource) {
137+
return next(
138+
createHttpError(HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found`)
139+
);
140+
}
141+
142+
// set req.userOrgId to source org for permission check
143+
req.userOrgId = resource.orgId;
144+
145+
if (resource.orgId === targetOrgId) {
146+
return next(
147+
createHttpError(HttpCode.BAD_REQUEST, `Resource is already in this organization`)
148+
);
149+
}
150+
151+
const [currentOrg] = await db
152+
.select()
153+
.from(orgs)
154+
.where(eq(orgs.orgId, resource.orgId))
155+
.limit(1);
156+
157+
const [targetOrg] = await db
158+
.select()
159+
.from(orgs)
160+
.where(eq(orgs.orgId, targetOrgId))
161+
.limit(1);
162+
163+
if (!targetOrg) {
164+
return next(
165+
createHttpError(HttpCode.NOT_FOUND, `Target organization with ID ${targetOrgId} not found`)
166+
);
167+
}
168+
169+
const [userOrgAccess] = await db
170+
.select()
171+
.from(userOrgs)
172+
.where(and(
173+
eq(userOrgs.userId, user.userId),
174+
eq(userOrgs.orgId, targetOrgId)
175+
))
176+
.limit(1);
177+
178+
if (!userOrgAccess) {
179+
return next(
180+
createHttpError(HttpCode.FORBIDDEN, `You don't have access to the target organization`)
181+
);
182+
}
183+
184+
// get role-based permissions that will be affected
185+
const rolePermissionsQuery = await db
186+
.select({
187+
roleId: roleResources.roleId,
188+
roleName: roles.name,
189+
roleDescription: roles.description
190+
})
191+
.from(roleResources)
192+
.innerJoin(roles, eq(roleResources.roleId, roles.roleId))
193+
.where(eq(roleResources.resourceId, resourceId));
194+
195+
// get user permissions that will be affected (excluding moving user)
196+
const userPermissionsQuery = await db
197+
.select({
198+
userId: userResources.userId,
199+
username: users.username,
200+
email: users.email,
201+
name: users.name
202+
})
203+
.from(userResources)
204+
.innerJoin(users, eq(userResources.userId, users.userId))
205+
.where(and(
206+
eq(userResources.resourceId, resourceId),
207+
// exclude the moving user from the impact as they'll retain access
208+
// Note: We'll show this in the UI but they won't "lose" access
209+
));
210+
211+
// Separate moving user from others who will lose access
212+
const movingUserPermission = userPermissionsQuery.find(up => up.userId === user.userId);
213+
const otherUserPermissions = userPermissionsQuery.filter(up => up.userId !== user.userId);
214+
215+
const totalImpactedPermissions = rolePermissionsQuery.length + otherUserPermissions.length;
216+
217+
const impactData = {
218+
resourceId: resource.resourceId,
219+
resourceName: resource.name,
220+
currentOrgId: resource.orgId,
221+
currentOrgName: currentOrg?.name || 'Unknown',
222+
targetOrgId,
223+
targetOrgName: targetOrg.name,
224+
impact: {
225+
rolePermissions: {
226+
count: rolePermissionsQuery.length,
227+
details: rolePermissionsQuery.map(rp => ({
228+
roleId: rp.roleId,
229+
roleName: rp.roleName,
230+
roleDescription: rp.roleDescription
231+
}))
232+
},
233+
userPermissions: {
234+
count: otherUserPermissions.length,
235+
details: otherUserPermissions.map(up => ({
236+
userId: up.userId,
237+
username: up.username,
238+
email: up.email || '',
239+
name: up.name || ''
240+
}))
241+
},
242+
movingUser: movingUserPermission ? {
243+
userId: movingUserPermission.userId,
244+
username: movingUserPermission.username,
245+
email: movingUserPermission.email || '',
246+
name: movingUserPermission.name || '',
247+
retainsAccess: true
248+
} : null,
249+
totalImpactedPermissions,
250+
authenticationPreserved: true, // Passwords, pins, etc. are preserved
251+
movingUserRetainsAccess: true
252+
}
253+
};
254+
255+
logger.info(`Move impact calculated for resource ${resourceId}`, {
256+
resourceId,
257+
currentOrgId: resource.orgId,
258+
targetOrgId,
259+
userId: user.userId,
260+
rolePermissionsAffected: rolePermissionsQuery.length,
261+
userPermissionsAffected: otherUserPermissions.length,
262+
totalImpact: totalImpactedPermissions
263+
});
264+
265+
return response(res, {
266+
data: impactData,
267+
success: true,
268+
error: false,
269+
message: "Move impact analysis completed successfully",
270+
status: HttpCode.OK,
271+
});
272+
273+
} catch (err) {
274+
logger.error("Error calculating move impact:", err);
275+
return next(
276+
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while calculating move impact")
277+
);
278+
}
279+
}

server/routers/resource/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ export * from "./deleteResourceRule";
2323
export * from "./listResourceRules";
2424
export * from "./updateResourceRule";
2525
export * from "./getUserResources";
26-
export * from "./moveResourceToOrg";
26+
export * from "./moveResourceToOrg";
27+
export * from "./getMoveImpact";

0 commit comments

Comments
 (0)