Merge pull request #1163 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-07 15:26:32 +03:00
committed by GitHub
35 changed files with 1142 additions and 819 deletions

View File

@@ -1,6 +1,7 @@
import {
BOOKING_RULE_ENGINE_PERMISSIONS,
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
deriveReadPermissions,
POSITION_PERMISSION_PRESETS,
ROLE_PERMISSION_PRESETS,
} from './freight-permissions.registry';
@@ -190,7 +191,7 @@ const POSITION_TYPE_PERMISSIONS = [
},
] as const;
export const EDR_FREIGHT_PERMISSIONS = [
const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
...EMPLOYEE_REGISTRATION_PERMISSIONS,
...ROLE_ASSIGNMENT_PERMISSIONS,
...HIERARCHY_UNIT_PERMISSIONS,
@@ -200,6 +201,15 @@ export const EDR_FREIGHT_PERMISSIONS = [
...BOOKING_RULE_ENGINE_PERMISSIONS,
];
export const EDR_FREIGHT_PERMISSIONS = [
...EDR_FREIGHT_VIEWABLE_PERMISSIONS,
// API-read twin of every `:view` key above — grants the module's GET routes
// without putting it in the backoffice sidebar. Seeded so they can be
// assigned; no role or position preset below grants one, that stays
// hand-curated in iam.position_type_permissions.
...deriveReadPermissions(EDR_FREIGHT_VIEWABLE_PERMISSIONS),
];
export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry';
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [

View File

@@ -1,4 +1,5 @@
import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed';
import { readTwinOf } from './freight-permissions.registry';
describe('EDR_FREIGHT_PERMISSIONS', () => {
// The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE
@@ -10,4 +11,40 @@ describe('EDR_FREIGHT_PERMISSIONS', () => {
expect(duplicates).toEqual([]);
});
// The `:read` ids are derived rather than hand-written, so a collision
// would surface here instead of as a primary-key violation on a fresh
// database.
it('has no duplicate ids', () => {
const ids = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.id);
const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))];
expect(duplicates).toEqual([]);
});
// `:read` grants a module's GET routes without putting it in the backoffice
// sidebar. Every `:view` needs its twin or that module has no way to be
// granted API-only access.
it('gives every :view key a :read twin', () => {
const keys = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key));
const missing = [...keys]
.filter((key) => key.endsWith(':view'))
.map((key) => readTwinOf(key))
.filter((twin): twin is string => twin !== null && !keys.has(twin));
expect(missing).toEqual([]);
});
it('mints every :read id in the v5 block', () => {
const reads = EDR_FREIGHT_PERMISSIONS.filter((p) => p.key.endsWith(':read'));
expect(reads.length).toBeGreaterThan(0);
// Version nibble 5 — the source `:view` ids are all v4, so the two sets
// cannot overlap however many keys are added.
for (const read of reads) {
expect(read.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
}
});
});

View File

@@ -31,6 +31,15 @@ export type RuleEngineResourceSlug =
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
slug.replace(/-/g, "_");
const VIEW_KEY_SUFFIX = ":view";
const READ_KEY_SUFFIX = ":read";
/** The `:read` twin of a `:view` key, or null if `key` is not a view key. */
export const readTwinOf = (key: string): string | null =>
key.endsWith(VIEW_KEY_SUFFIX)
? `${key.slice(0, -VIEW_KEY_SUFFIX.length)}${READ_KEY_SUFFIX}`
: null;
const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({
id,
key,
@@ -1359,6 +1368,59 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS =
BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key);
/**
* Id for a derived `:read` key: the source `:view` id with its version nibble
* moved 4 → 5.
*
* Unique because the source ids are, and disjoint from every hand-written id
* because those are all v4-shaped. Nothing index-derived, so a new `:view`
* landing mid-list cannot shift ids already seeded — the trap the
* RULE_ENGINE_RESOURCE_SLUGS comment warns about.
*
* (`EdrOrgSeeder.ensurePermissions` deliberately never sends an id — the
* column default wins and `key` is the identity every consumer resolves by.
* These exist to satisfy the seed type and keep the array self-consistent.)
*/
const readPermissionId = (viewId: string): string =>
`${viewId.slice(0, 14)}5${viewId.slice(15)}`;
/**
* API-read twin of every `:view` key.
*
* `:view` does three jobs at once — backoffice sidebar entry (App.tsx), route
* admission (RequirePermission), and API read. That bundling means a user who
* only needs another module's list endpoint for a form dropdown has to be
* granted the whole module, page and all. `<module>:read` unbundles it: the
* guard accepts it wherever the matching `:view` is required on a GET, and the
* frontend never looks at it, so the module stays out of the menu.
*
* Derived rather than hand-written so a new `:view` key gets its twin for
* free. Grants stay hand-curated in `iam.position_type_permissions` — nothing
* here hands a `:read` to anyone.
*/
export const deriveReadPermissions = (
seeds: readonly FreightPermissionSeed[],
): FreightPermissionSeed[] =>
seeds
.filter((p) => p.key.endsWith(VIEW_KEY_SUFFIX))
.map((p) =>
perm(
readPermissionId(p.id),
readTwinOf(p.key) as string,
`Read ${p.name.en.replace(/^View /, "")} (API only)`,
),
);
/**
* Read twins of the freight-domain catalog, for `PERMISSIONS_CATALOG`. The
* IAM/hierarchy keys seeded alongside it live in `edr-freight.seed.ts` and get
* theirs there — each twin is derived from its own source row, so the two call
* sites agree on any key they share without coordination.
*/
export const FREIGHT_READ_PERMISSIONS = deriveReadPermissions(
BOOKING_RULE_ENGINE_PERMISSIONS,
);
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
@@ -2079,7 +2141,10 @@ export const POSITION_PERMISSION_PRESETS = {
/** Derive the module bucket from the resource segment of a permission key. */
const moduleOf = (key: string): string => key.split(":")[1] ?? "other";
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
export const PERMISSIONS_CATALOG = [
...BOOKING_RULE_ENGINE_PERMISSIONS,
...FREIGHT_READ_PERMISSIONS,
].map((p) => ({
key: p.key,
label: p.name.en,
module: moduleOf(p.key),