rea/src/application/ProjectPermissionStore.ts

272 lines
8.5 KiB
TypeScript

import {
createStoreFileLock,
removeStaleStoreFileLock,
type StoreFileLock,
} from "./StoreFileLock.js";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import {
lstat,
mkdir,
open,
realpath,
type FileHandle,
} from "node:fs/promises";
import { dirname } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import {
PERMISSION_CAPABILITIES,
type PermissionGrant,
} from "../domain/permissionPolicy.js";
import { err, ok, type Result } from "../domain/result.js";
const grantSchema = z.object({
grant_id: z.string().min(1),
capability: z.enum(PERMISSION_CAPABILITIES),
roots: z.array(z.string()),
executables: z.array(z.string()),
environment_names: z.array(z.string()),
origins: z.array(z.string()).optional(),
network: z.enum(["none", "loopback", "external"]),
mount: z.boolean(),
lifetime: z.literal("project"),
operation_identity: z.string().nullable(),
expires_at: z.iso.datetime().nullable(),
});
const storeSchema = z.object({
project_id: z.string().regex(/^project_[a-f0-9]{64}$/u),
project_root: z.string(),
grants: z.array(grantSchema).max(1_000),
});
export type ProjectPermissionStore = z.infer<typeof storeSchema>;
/** Owner-only project policy persistence failure. */
export class ProjectPermissionStoreError extends Error {
readonly _tag = "ProjectPermissionStoreError" as const;
constructor(
readonly reason:
| "project_not_found"
| "not_owner_only"
| "invalid"
| "locked"
| "io",
options?: ErrorOptions,
) {
super(`Project permission store failed: ${reason}`, options);
}
}
/** Derive relocation-explicit project identity from its canonical root. */
const identifyPermissionProject = async (
projectRoot: string,
): Promise<
Result<
{ readonly id: string; readonly root: string },
ProjectPermissionStoreError
>
> => {
try {
const root = await realpath(projectRoot);
return ok({
id: `project_${createHash("sha256").update(root).digest("hex")}`,
root,
});
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("project_not_found", { cause }));
}
};
/** Read and validate an owner-only store bound to the requested project. */
export const readProjectPermissionStore = async (
path: string,
projectRoot: string,
): Promise<
Result<ProjectPermissionStore | null, ProjectPermissionStoreError>
> => {
const project = await identifyPermissionProject(projectRoot);
if (!project.ok) return project;
if (process.getuid === undefined)
return err(new ProjectPermissionStoreError("not_owner_only"));
let handle: FileHandle | undefined;
try {
const opened = await openPrivateStore(path);
if (!opened.ok) return opened;
if (opened.value === null) return ok(null);
handle = opened.value;
const encoded = await handle.readFile("utf8");
let decoded: unknown;
try {
decoded = JSON.parse(encoded);
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("invalid", { cause }));
}
const parsed = storeSchema.safeParse(decoded);
if (
!parsed.success ||
parsed.data.project_id !== project.value.id ||
parsed.data.project_root !== project.value.root
)
return err(new ProjectPermissionStoreError("invalid"));
return ok(parsed.data);
} catch (cause: unknown) {
if (isNotFound(cause)) return ok(null);
return err(new ProjectPermissionStoreError("io", { cause }));
} finally {
await handle?.close().catch(() => undefined);
}
};
/** Atomically replace explicit project grants with owner-only permissions. */
export const writeProjectPermissionStore = async (
path: string,
projectRoot: string,
grants: readonly PermissionGrant[],
): Promise<Result<ProjectPermissionStore, ProjectPermissionStoreError>> => {
const project = await identifyPermissionProject(projectRoot);
if (!project.ok) return project;
const candidate = storeSchema.safeParse({
project_id: project.value.id,
project_root: project.value.root,
grants,
});
if (!candidate.success)
return err(
new ProjectPermissionStoreError("invalid", { cause: candidate.error }),
);
if (process.getuid === undefined)
return err(new ProjectPermissionStoreError("not_owner_only"));
return withPermissionStoreLock(path, () =>
writePreparedPermissionStore(path, projectRoot, candidate.data),
);
};
const writePreparedPermissionStore = async (
path: string,
projectRoot: string,
candidate: ProjectPermissionStore,
): Promise<Result<ProjectPermissionStore, ProjectPermissionStoreError>> => {
await writeFileAtomic(path, `${JSON.stringify(candidate, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
const verified = await readProjectPermissionStore(path, projectRoot);
if (!verified.ok) return verified;
if (
verified.value === null ||
JSON.stringify(verified.value) !== JSON.stringify(candidate)
)
return err(new ProjectPermissionStoreError("invalid"));
return ok(candidate);
};
/** Revoke one grant while serializing the complete read-modify-write cycle. */
export const revokeProjectPermissionGrant = async (
path: string,
projectRoot: string,
grantId: string,
): Promise<Result<boolean, ProjectPermissionStoreError>> =>
withPermissionStoreLock(path, async () => {
const current = await readProjectPermissionStore(path, projectRoot);
if (!current.ok) return current;
if (current.value === null) return ok(false);
const retained = current.value.grants.filter(
({ grant_id }) => grant_id !== grantId,
);
if (retained.length === current.value.grants.length) return ok(false);
const written = await writePreparedPermissionStore(path, projectRoot, {
...current.value,
grants: retained,
});
return written.ok ? ok(true) : written;
});
const withPermissionStoreLock = async <Value>(
path: string,
action: () => Promise<Result<Value, ProjectPermissionStoreError>>,
): Promise<Result<Value, ProjectPermissionStoreError>> => {
let lock: StoreFileLock | undefined;
try {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const acquired = await acquirePermissionStoreLock(path);
if (!acquired.ok) return acquired;
lock = acquired.value;
return await action();
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("io", { cause }));
} finally {
if (lock !== undefined) await lock.release();
}
};
const acquirePermissionStoreLock = async (
destination: string,
): Promise<Result<StoreFileLock, ProjectPermissionStoreError>> => {
const path = `${destination}.lock`;
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
return ok(await createStoreFileLock(path));
} catch (cause: unknown) {
if (!isAlreadyExists(cause))
return err(new ProjectPermissionStoreError("io", { cause }));
if (await removeStaleStoreFileLock(path)) continue;
if (attempt === 99)
return err(new ProjectPermissionStoreError("locked", { cause }));
await delay(10);
}
}
return err(new ProjectPermissionStoreError("locked"));
};
const isNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT";
const openPrivateStore = async (
path: string,
): Promise<Result<FileHandle | null, ProjectPermissionStoreError>> => {
let handle: FileHandle | undefined;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const metadata = await handle.stat();
if (!privateRegularFile(metadata)) {
await handle.close();
return err(new ProjectPermissionStoreError("not_owner_only"));
}
return ok(handle);
} catch (cause: unknown) {
await handle?.close().catch(() => undefined);
if (isNotFound(cause)) return ok(null);
return err(
new ProjectPermissionStoreError(
isSymlinkRefusal(cause) ? "not_owner_only" : "io",
{ cause },
),
);
}
};
const privateRegularFile = (
metadata: Awaited<ReturnType<typeof lstat>>,
): boolean =>
metadata.isFile() &&
!metadata.isSymbolicLink() &&
(Number(metadata.mode) & 0o077) === 0 &&
process.getuid !== undefined &&
metadata.uid === process.getuid();
const isAlreadyExists = (cause: unknown): boolean =>
errorCode(cause) === "EEXIST";
const errorCode = (cause: unknown): unknown =>
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;
const isSymlinkRefusal = (cause: unknown): boolean =>
errorCode(cause) === "ELOOP" || errorCode(cause) === "EMLINK";