mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
feat(auth): add <module>:read for API access without UI exposure
`<module>:view` gates the backoffice sidebar entry, the route, and the API read all at once, so granting a user another module's list endpoint for a form dropdown also hands them that module's whole page. Seed a `:read` twin for every `:view` key and teach the freight guards to accept it wherever the matching `:view` is required — on GET/HEAD/OPTIONS only, since class and method guards AND together and a write route without its own method gate would otherwise be reachable. The frontend never checks `:read`, which is what keeps the module hidden. Twins are derived, not hand-written, so a new `:view` gets one for free. Grants stay hand-curated in iam.position_type_permissions.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
|
||||
import { readTwinOf } from '../seed/freight-permissions.registry';
|
||||
|
||||
// String literals on purpose (same reasoning as login-audience.middleware.ts):
|
||||
// the values are wire-format constants from iam.users.user_type, and importing
|
||||
@@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined =>
|
||||
const isEmployee = (user: TCurrentUser): boolean =>
|
||||
userTypeOf(user) === 'employee' || isSuperAdmin(user);
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
* Does the caller satisfy a required permission?
|
||||
*
|
||||
* Holding the key outright always passes. A required `<module>:view` is ALSO
|
||||
* satisfied by the weaker `<module>:read` — the key that buys API reads
|
||||
* without putting the module in the backoffice sidebar — but only on a safe
|
||||
* HTTP method.
|
||||
*
|
||||
* The method restriction is load-bearing, not caution. Nest runs class AND
|
||||
* method guards, so controllers list every route key on the class gate,
|
||||
* `:view` among the write keys. Without this check a `:read` holder would
|
||||
* clear that class gate and then reach any write route that has no method
|
||||
* gate of its own. Keying on the HTTP verb closes that by construction rather
|
||||
* than by an audit that goes stale the next time a route is added.
|
||||
*/
|
||||
const satisfiedBy = (
|
||||
user: TCurrentUser,
|
||||
required: string,
|
||||
method: string,
|
||||
): boolean => {
|
||||
if (hasFreightPermission(user, required)) return true;
|
||||
if (!SAFE_METHODS.has(method)) return false;
|
||||
const readTwin = readTwinOf(required);
|
||||
return Boolean(readTwin && hasFreightPermission(user, readTwin));
|
||||
};
|
||||
|
||||
export function FreightPermissionGuard(
|
||||
permissions: string[],
|
||||
): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class FreightPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: TCurrentUser; method: string }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
@@ -39,7 +70,7 @@ export function FreightPermissionGuard(
|
||||
}
|
||||
|
||||
if (!permissions?.length) return true;
|
||||
if (permissions.some((p) => hasFreightPermission(user, p))) {
|
||||
if (permissions.some((p) => satisfiedBy(user, p, request.method))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class MixedAudiencesGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: TCurrentUser; method: string }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
@@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
|
||||
}
|
||||
if (
|
||||
!permissions?.length ||
|
||||
permissions.some((p) => hasFreightPermission(user, p))
|
||||
permissions.some((p) => satisfiedBy(user, p, request.method))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user