Enforce a permission and record the decision
Check an owner policy, enforce token scopes and inspect the resulting audit entries.
A disabled edit button is useful feedback, but the save operation must enforce the permission too. This guide defines an owner-only policy and shows what changes when you call check or require.
The example runs the real Access and Audit packages in your browser. Audit entries use TestAuditLog.layer, an in-memory implementation intended for tests and demonstrations.
Try the outcomes
Each run starts fresh. Nothing is sent to a storage service.
Result
Choose inputs, then run the example.- Run as Ada with require. The note changes and an
Allowentry is recorded. - Switch to Bo. The operation returns
AccessDenied, leaves the note unchanged and recordsDenywithdeniedBy: "policy". - Switch back to Ada and enable Token has no edit scope. Even the owner is refused. The audit details say
deniedBy: "restriction". - Switch to check. It returns
AlloworDenywithout saving or recording an audit entry.
The playground catches AccessDenied to display it. In an application, handle that error at the request boundary; do not catch it and continue with the protected write.
Run the same code locally
Use Node.js 24 and install the packages:
pnpm add @kutur-kit/access@alpha @kutur-kit/audit@alpha effectSave this as permission-demo.ts, then run node permission-demo.ts. This is the implementation used by the playground, followed by a sample invocation.
import { Decision, Permission } from "@kutur-kit/access";
import * as PermissionAudit from "@kutur-kit/access/audit";
import { Audit, AuditKey } from "@kutur-kit/audit";
import { TestAuditLog } from "@kutur-kit/audit/testing";
import { Context, Effect, Layer, Schema } from "effect";
class CurrentUser extends Context.Service<CurrentUser, { readonly id: string }>()(
"demo/CurrentUser",
) {}
const Note = Schema.Struct({
id: Schema.String.pipe(AuditKey.key),
ownerId: Schema.String,
body: Schema.String,
});
// EditNote's methods require CurrentUser from every caller on purpose: the policy reads the
// caller of each request. The directive stops the Effect language service reporting it as leaking.
/** @effect-expect-leaking CurrentUser */
class EditNote extends Permission.Service<EditNote, { requires: CurrentUser }>()("demo/EditNote", {
id: "notes.edit",
target: Note,
}) {}
const policy = EditNote.toLayer(
Effect.fn(function* (note) {
const user = yield* CurrentUser;
return user.id === note.ownerId ? Decision.allow() : Decision.deny("Only the owner can edit");
}),
).pipe(PermissionAudit.record(EditNote), Layer.provideMerge(TestAuditLog.layer));
export interface PermissionInput {
readonly userId: "ada" | "bo";
readonly mode: "check" | "require";
readonly restricted: boolean;
}
export const permissionDemo = (input: PermissionInput) =>
Effect.gen(function* () {
const note = { id: "note-1", ownerId: "ada", body: "Original note" };
const action =
input.mode === "check"
? EditNote.check(note).pipe(Effect.map((decision) => decision._tag))
: EditNote.require(note).pipe(
Effect.andThen(
Effect.sync(() => {
note.body = "Updated note";
}),
),
Effect.as("Saved"),
Effect.catchTag("AccessDenied", () => Effect.succeed("AccessDenied")),
);
const result = yield* action.pipe(Permission.restrictTo(input.restricted ? [] : [EditNote]));
const entries = yield* TestAuditLog.entries;
return {
result,
body: note.body,
audit: entries.map(({ action, outcome, target, actor, details, reason }) => ({
action,
outcome,
target,
actor,
details,
reason,
})),
};
}).pipe(
Audit.withActor({ id: input.userId, kind: "user" }),
Effect.provideService(CurrentUser, { id: input.userId }),
Effect.provide(policy),
);
Effect.runPromise(permissionDemo({ userId: "ada", mode: "require", restricted: false })).then(console.log);How the pieces fit
CurrentUser describes the identity the policy needs. requires: CurrentUser keeps that requirement on permission calls. The policy reads it when deciding whether the caller owns the note.
EditNote.toLayer provides the policy implementation. PermissionAudit.record(EditNote) wraps it so enforced decisions become audit events. Layer.provideMerge(TestAuditLog.layer) supplies the log and keeps its reader available to the program.
Permission.restrictTo limits which permissions the caller may use. It does not grant access: the owner policy must still allow the edit. Restrictions compose by narrowing access.
Only the note's id has AuditKey.key, so the default audit view records { id: "note-1" }. The body and owner ID are omitted. Audit.withActor records who acted separately from the target.
Use it in a server
Obtain the caller from a verified session or token, then provide CurrentUser at the request boundary. Never trust the caller ID selected by a browser: these controls only demonstrate policy behavior.
Keep require immediately before the protected operation. If ownership can change concurrently, enforce the condition in the same transaction as the write or use a conditional write. A separate permission check does not make a later database update atomic.
Replace the test log with PostgreSQL audit storage. The memory log does not demonstrate transaction rollback, durable storage or isolated writes. For list endpoints, use a permission filter so denied records are excluded before pagination.