feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract (packages/contracts/src/authorization.ts) — id/baseline/description, strict, unknown-property-rejecting — consumed by both Core's authorization registry and module manifests, replacing Core's previously separate interface. Bumps the canonical module manifest to manifestVersion 3: permissions become complete declarations instead of bare identifier strings, with manifest-owned namespace and duplicate-id enforcement. Version 2 and bare string-array permissions are rejected outright; there is no compatibility adapter. Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts), a pure, synchronous composer that builds one platform permission registry from Core's own declarations plus every already-discovered module's, in deterministic order, reusing createPermissionRegistry and rejecting any module-contributed permission in the reserved core.* namespace. core/src/authorization remains independent of core/src/modules; only the module integration layer depends on authorization. Updates the module template, module-development docs, authorization docs, and existing manifest fixtures for manifestVersion 3. Does not wire the combined registry into apps/api — that is pass 2.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { permissionIdSchema } from "./identifiers.js";
|
||||
import type { EkklesiaUserId } from "./identity.js";
|
||||
import { parseEkklesiaUserId } from "./identity.js";
|
||||
|
||||
@@ -51,6 +52,44 @@ export const permissionBaselineSchema = z.enum(["public", "authenticated", "rest
|
||||
|
||||
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
|
||||
|
||||
/**
|
||||
* The one authoritative shape of a *registered* permission declaration — what both Core's
|
||||
* authorization registry (`core/src/authorization/permissions.ts`) and a module manifest's
|
||||
* `permissions` array parse into, so a permission a module declares and a permission Core's
|
||||
* registry enforces are provably the same structure, never two independently maintained
|
||||
* interfaces that could quietly drift apart. `.strict()` rejects any property beyond these three —
|
||||
* there is no room for a module or a future caller to smuggle extra, unvalidated metadata into a
|
||||
* declaration Core treats as trusted.
|
||||
*
|
||||
* This schema says nothing about *registration*: parsing a value into a `PermissionDeclaration`
|
||||
* only proves it is shaped correctly, not that any registry has accepted it. Registration remains
|
||||
* exclusively `PermissionRegistry`'s responsibility (`core/src/authorization/permissions.ts`) —
|
||||
* built by composing exactly the declarations a trusted caller supplies, Core's own plus every
|
||||
* discovered module's.
|
||||
*/
|
||||
export const permissionDeclarationSchema = z
|
||||
.object({
|
||||
id: permissionIdSchema,
|
||||
baseline: permissionBaselineSchema,
|
||||
/** Operator/audit-facing text only. Authorization never reads, parses, or otherwise
|
||||
* interprets this value — changing it can never change what a permission allows. */
|
||||
description: z.string().trim().min(1).max(240),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PermissionDeclarationInput = z.input<typeof permissionDeclarationSchema>;
|
||||
export type PermissionDeclaration = z.output<typeof permissionDeclarationSchema>;
|
||||
|
||||
export function parsePermissionDeclaration(input: unknown): PermissionDeclaration {
|
||||
return permissionDeclarationSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParsePermissionDeclaration(
|
||||
input: unknown,
|
||||
): z.ZodSafeParseResult<PermissionDeclaration> {
|
||||
return permissionDeclarationSchema.safeParse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
|
||||
* created as a side effect of its own business operation, attributed to some source the module
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
logicalBucketIdSchema,
|
||||
moduleIdSchema,
|
||||
packageRelativePathSchema,
|
||||
permissionIdSchema,
|
||||
} from "./identifiers.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
@@ -89,7 +89,7 @@ function reportDuplicates(
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(2),
|
||||
manifestVersion: z.literal(3),
|
||||
id: moduleIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
@@ -102,7 +102,7 @@ export const moduleManifestSchema = z
|
||||
.strict(),
|
||||
dependencies: dependenciesSchema,
|
||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||
permissions: z.array(permissionIdSchema).default(() => []),
|
||||
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
entrypoints: entrypointsSchema,
|
||||
})
|
||||
@@ -114,7 +114,12 @@ export const moduleManifestSchema = z
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(manifest.permissions, ["permissions"], context);
|
||||
reportDuplicates(
|
||||
manifest.permissions.map((permission) => permission.id),
|
||||
["permissions"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
["storage", "buckets"],
|
||||
@@ -133,11 +138,11 @@ export const moduleManifestSchema = z
|
||||
});
|
||||
|
||||
manifest.permissions.forEach((permission, index) => {
|
||||
if (!permission.startsWith(`${manifest.id}.`)) {
|
||||
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["permissions", index],
|
||||
path: ["permissions", index, "id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -197,3 +202,7 @@ export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
||||
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
|
||||
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
|
||||
* discoverability alongside the manifest's other per-field declaration types. */
|
||||
export type ModulePermissionDeclaration = PermissionDeclaration;
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
isUserActor,
|
||||
parseAuthorizationScope,
|
||||
parseGrantId,
|
||||
parsePermissionDeclaration,
|
||||
parsePermissionId,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
safeParseAuthorizationScope,
|
||||
safeParseGrantId,
|
||||
safeParsePermissionDeclaration,
|
||||
safeParsePermissionId,
|
||||
SYSTEM_ACTOR,
|
||||
} from "../src/index.js";
|
||||
@@ -50,6 +53,87 @@ describe("PermissionId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionDeclaration", () => {
|
||||
function validDeclaration(): Record<string, unknown> {
|
||||
return {
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read calendar events.",
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
"public",
|
||||
"authenticated",
|
||||
"restricted",
|
||||
] as const)("parses a well-formed declaration with baseline %s", (baseline) => {
|
||||
const declaration = parsePermissionDeclaration({ ...validDeclaration(), baseline });
|
||||
expect(declaration).toEqual({
|
||||
id: "calendar.events.read",
|
||||
baseline,
|
||||
description: "Read calendar events.",
|
||||
});
|
||||
});
|
||||
|
||||
it("is exactly three fields — this is the one shape both a module manifest and Core's registry parse into", () => {
|
||||
const declaration: PermissionDeclaration = parsePermissionDeclaration(validDeclaration());
|
||||
expect(Object.keys(declaration).sort()).toEqual(["baseline", "description", "id"]);
|
||||
});
|
||||
|
||||
it("rejects an unknown declaration property", () => {
|
||||
const result = safeParsePermissionDeclaration({
|
||||
...validDeclaration(),
|
||||
scope: "calendar-event:123",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing baseline", () => {
|
||||
const { baseline: _baseline, ...withoutBaseline } = validDeclaration();
|
||||
expect(safeParsePermissionDeclaration(withoutBaseline).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an invalid baseline", () => {
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), baseline: "superuser" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing, empty, or whitespace-only description", () => {
|
||||
const { description: _description, ...withoutDescription } = validDeclaration();
|
||||
expect(safeParsePermissionDeclaration(withoutDescription).success).toBe(false);
|
||||
expect(safeParsePermissionDeclaration({ ...validDeclaration(), description: "" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), description: " " }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a description beyond the fixed maximum length", () => {
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(240) })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(241) })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed permission id", () => {
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), id: "not-namespaced" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never registers or evaluates anything — parsing only proves the value is well-formed", () => {
|
||||
// Nothing here consults a registry: `parsePermissionDeclaration` succeeding says nothing
|
||||
// about whether `calendar.events.read` is actually registered anywhere.
|
||||
expect(() => parsePermissionDeclaration(validDeclaration())).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrantId", () => {
|
||||
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
||||
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
MODULE_LIFECYCLE_STAGES,
|
||||
packageRelativePathSchema,
|
||||
parseModuleManifest,
|
||||
parsePermissionId,
|
||||
safeParseModuleManifest,
|
||||
} from "../src/index.js";
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
@@ -18,7 +19,13 @@ function validManifest(): Record<string, unknown> {
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: [{ id: "calendar.events", version: "2.3.0" }],
|
||||
permissions: ["calendar.events.read"],
|
||||
permissions: [
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read calendar events.",
|
||||
},
|
||||
],
|
||||
storage: {
|
||||
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
||||
},
|
||||
@@ -45,16 +52,23 @@ describe("module manifest contract", () => {
|
||||
it("creates isolated default collections for every parsed manifest", () => {
|
||||
const input = validManifest();
|
||||
delete input.storage;
|
||||
delete input.permissions;
|
||||
const first = parseModuleManifest(input);
|
||||
const second = parseModuleManifest(input);
|
||||
|
||||
first.dependencies.modules.required.people = "^0.1.0";
|
||||
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
|
||||
first.storage.buckets.push({ id: "exports" });
|
||||
first.permissions.push({
|
||||
id: parsePermissionId("calendar.events.write"),
|
||||
baseline: "restricted",
|
||||
description: "Write calendar events.",
|
||||
});
|
||||
|
||||
expect(second.dependencies.modules.required).toEqual({});
|
||||
expect(second.dependencies.capabilities.required).toEqual({});
|
||||
expect(second.storage.buckets).toEqual([]);
|
||||
expect(second.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects unknown and presentation-oriented manifest properties", () => {
|
||||
@@ -68,14 +82,16 @@ describe("module manifest contract", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
capabilities: [{ id: "media.items", version: "1.0.0" }],
|
||||
permissions: ["media.items.read"],
|
||||
permissions: [
|
||||
{ id: "media.items.read", baseline: "restricted", description: "Read media items." },
|
||||
],
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["capabilities.0.id", "permissions.0"]),
|
||||
expect.arrayContaining(["capabilities.0.id", "permissions.0.id"]),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -107,6 +123,32 @@ describe("module manifest contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a duplicate permission id at its exact issue path", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read calendar events.",
|
||||
},
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "authenticated",
|
||||
description: "Read calendar events, again.",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"permissions.1.id",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects module and capability self-dependencies", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
@@ -151,6 +193,113 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 2 manifest rather than silently interpreting it as version 3", () => {
|
||||
// manifestVersion 2's own permissions shape (a bare string array) is also what version 3
|
||||
// must reject — this manifest is invalid twice over, deliberately, since a real version 2
|
||||
// manifest is exactly this shape.
|
||||
const version2Manifest = {
|
||||
...validManifest(),
|
||||
manifestVersion: 2,
|
||||
permissions: ["calendar.events.read"],
|
||||
};
|
||||
|
||||
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
||||
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
||||
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to an empty permission list when omitted", () => {
|
||||
const input = validManifest();
|
||||
delete input.permissions;
|
||||
|
||||
const manifest = parseModuleManifest(input);
|
||||
expect(manifest.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"public",
|
||||
"authenticated",
|
||||
"restricted",
|
||||
] as const)("accepts a %s permission declaration and preserves its exact baseline", (baseline) => {
|
||||
const manifest = parseModuleManifest({
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", baseline, description: "Read events." }],
|
||||
});
|
||||
expect(manifest.permissions).toEqual([
|
||||
{ id: "calendar.events.read", baseline, description: "Read events." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with a missing baseline", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", description: "Read events." }],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with an invalid baseline", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{ id: "calendar.events.read", baseline: "superuser", description: "Read events." },
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with a missing or blank description", () => {
|
||||
const missingDescription = {
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", baseline: "restricted" }],
|
||||
};
|
||||
const blankDescription = {
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", baseline: "restricted", description: " " }],
|
||||
};
|
||||
expect(safeParseModuleManifest(missingDescription).success).toBe(false);
|
||||
expect(safeParseModuleManifest(blankDescription).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with an oversized description", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "x".repeat(241) },
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with an unknown property", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read events.",
|
||||
scope: "calendar-event:123",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed permission id", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{ id: "Calendar.Events.Read", baseline: "restricted", description: "Read events." },
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../outside.ts",
|
||||
"/absolute.ts",
|
||||
|
||||
Reference in New Issue
Block a user