@kutur-kit/access 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/dist/Condition.d.ts +76 -0
  4. package/dist/Condition.d.ts.map +1 -0
  5. package/dist/Condition.js +43 -0
  6. package/dist/Condition.js.map +1 -0
  7. package/dist/Decision.d.ts +39 -0
  8. package/dist/Decision.d.ts.map +1 -0
  9. package/dist/Decision.js +11 -0
  10. package/dist/Decision.js.map +1 -0
  11. package/dist/Permission.d.ts +82 -0
  12. package/dist/Permission.d.ts.map +1 -0
  13. package/dist/Permission.js +114 -0
  14. package/dist/Permission.js.map +1 -0
  15. package/dist/PermissionFilter.d.ts +25 -0
  16. package/dist/PermissionFilter.d.ts.map +1 -0
  17. package/dist/PermissionFilter.js +33 -0
  18. package/dist/PermissionFilter.js.map +1 -0
  19. package/dist/audit.d.ts +27 -0
  20. package/dist/audit.d.ts.map +1 -0
  21. package/dist/audit.js +46 -0
  22. package/dist/audit.js.map +1 -0
  23. package/dist/drizzle/postgres.d.ts +18 -0
  24. package/dist/drizzle/postgres.d.ts.map +1 -0
  25. package/dist/drizzle/postgres.js +65 -0
  26. package/dist/drizzle/postgres.js.map +1 -0
  27. package/dist/index.d.ts +5 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +5 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/testing/PermissionTesting.d.ts +17 -0
  32. package/dist/testing/PermissionTesting.d.ts.map +1 -0
  33. package/dist/testing/PermissionTesting.js +19 -0
  34. package/dist/testing/PermissionTesting.js.map +1 -0
  35. package/dist/testing/index.d.ts +2 -0
  36. package/dist/testing/index.d.ts.map +1 -0
  37. package/dist/testing/index.js +2 -0
  38. package/dist/testing/index.js.map +1 -0
  39. package/package.json +88 -3
  40. package/src/Condition.ts +93 -0
  41. package/src/Decision.ts +45 -0
  42. package/src/Permission.ts +311 -0
  43. package/src/PermissionFilter.ts +93 -0
  44. package/src/audit.ts +82 -0
  45. package/src/drizzle/postgres.ts +108 -0
  46. package/src/index.ts +7 -0
  47. package/src/testing/PermissionTesting.ts +47 -0
  48. package/src/testing/index.ts +1 -0
@@ -0,0 +1,93 @@
1
+ import { Data, Match, Predicate } from "effect";
2
+
3
+ /** The values conditions compare. */
4
+ export type Value = string | number | boolean | bigint | null;
5
+
6
+ /** The fields of `T` a condition can compare: those holding primitive values. */
7
+ export type Field<T> = {
8
+ readonly [K in keyof T & string]: T[K] extends Value ? K : never;
9
+ }[keyof T & string];
10
+
11
+ export class All extends Data.TaggedClass("All")<{}> {}
12
+
13
+ export class None extends Data.TaggedClass("None")<{}> {}
14
+
15
+ export class Eq extends Data.TaggedClass("Eq")<{
16
+ readonly field: string;
17
+ readonly value: Value;
18
+ }> {}
19
+
20
+ export class OneOf extends Data.TaggedClass("OneOf")<{
21
+ readonly field: string;
22
+ readonly values: ReadonlyArray<Value>;
23
+ }> {}
24
+
25
+ export class And extends Data.TaggedClass("And")<{ readonly conditions: ReadonlyArray<Node> }> {}
26
+
27
+ export class Or extends Data.TaggedClass("Or")<{ readonly conditions: ReadonlyArray<Node> }> {}
28
+
29
+ export class Not extends Data.TaggedClass("Not")<{ readonly condition: Node }> {}
30
+
31
+ export type Node = All | None | Eq | OneOf | And | Or | Not;
32
+
33
+ /**
34
+ * A condition over targets of type `T`. The phantom member keeps conditions
35
+ * over one target type from being used for another.
36
+ */
37
+ export type Condition<T> = Node & { readonly "~target"?: (target: T) => T };
38
+
39
+ /** Builds conditions over one target type; PermissionFilter policies receive it. */
40
+ export interface Builder<T> {
41
+ readonly all: () => Condition<T>;
42
+ readonly none: () => Condition<T>;
43
+ readonly eq: <K extends Field<T>>(field: K, value: T[K] & Value) => Condition<T>;
44
+ readonly oneOf: <K extends Field<T>>(
45
+ field: K,
46
+ values: ReadonlyArray<T[K] & Value>,
47
+ ) => Condition<T>;
48
+ readonly and: (...conditions: ReadonlyArray<Condition<T>>) => Condition<T>;
49
+ readonly or: (...conditions: ReadonlyArray<Condition<T>>) => Condition<T>;
50
+ readonly not: (condition: Condition<T>) => Condition<T>;
51
+ }
52
+
53
+ export const make = <T>(): Builder<T> => ({
54
+ all: () => new All(),
55
+ none: () => new None(),
56
+ eq: (field, value) => new Eq({ field, value }),
57
+ oneOf: (field, values) => new OneOf({ field, values }),
58
+ and: (...conditions) => new And({ conditions }),
59
+ or: (...conditions) => new Or({ conditions }),
60
+ not: (condition) => new Not({ condition }),
61
+ });
62
+
63
+ // NaN equals NaN and 0 equals -0, as in SQL.
64
+ const same = (actual: Value | undefined, value: Value) =>
65
+ actual === value || Object.is(actual, value);
66
+
67
+ const isValue = (value: unknown): value is Value =>
68
+ value === null ||
69
+ Predicate.isString(value) ||
70
+ Predicate.isNumber(value) ||
71
+ Predicate.isBoolean(value) ||
72
+ Predicate.isBigInt(value);
73
+
74
+ const evaluate = (fields: ReadonlyMap<string, Value>): ((node: Node) => boolean) =>
75
+ Match.type<Node>().pipe(
76
+ Match.tagsExhaustive({
77
+ All: () => true,
78
+ None: () => false,
79
+ Eq: ({ field, value }) => same(fields.get(field), value),
80
+ OneOf: ({ field, values }) => values.some((value) => same(fields.get(field), value)),
81
+ And: ({ conditions }) => conditions.every(evaluate(fields)),
82
+ Or: ({ conditions }) => conditions.some(evaluate(fields)),
83
+ Not: ({ condition }) => !evaluate(fields)(condition),
84
+ }),
85
+ );
86
+
87
+ /** Whether a target satisfies a condition, as a query applying it would decide. */
88
+ export const matches = <T extends object>(condition: Condition<T>, target: T): boolean =>
89
+ evaluate(
90
+ new Map(
91
+ Object.entries(target).flatMap(([field, value]) => (isValue(value) ? [[field, value]] : [])),
92
+ ),
93
+ )(condition);
@@ -0,0 +1,45 @@
1
+ import { Data, Schema } from "effect";
2
+
3
+ export type Decision = Data.TaggedEnum<{
4
+ Allow: {};
5
+ /**
6
+ * `restriction` when the caller's restriction refused the permission before
7
+ * its policy ran; `requirement` when the policy required a permission, named
8
+ * by `requirement`, that refused.
9
+ */
10
+ Deny: {
11
+ readonly by: "restriction" | "policy" | "requirement";
12
+ readonly reason?: string;
13
+ readonly requirement?: string;
14
+ };
15
+ }>;
16
+
17
+ export type Allow = Extract<Decision, { readonly _tag: "Allow" }>;
18
+
19
+ export type Deny = Extract<Decision, { readonly _tag: "Deny" }>;
20
+
21
+ const constructors = Data.taggedEnum<Decision>();
22
+
23
+ export const allow = (): Allow => constructors.Allow();
24
+
25
+ export const deny = (reason?: string): Deny =>
26
+ constructors.Deny(reason === undefined ? { by: "policy" } : { by: "policy", reason });
27
+
28
+ export const restricted = (): Deny => constructors.Deny({ by: "restriction" });
29
+
30
+ export const unmet = (requirement: string): Deny =>
31
+ constructors.Deny({ by: "requirement", requirement });
32
+
33
+ export class Denied extends Schema.TaggedError<Denied, { readonly brand: unique symbol }>()(
34
+ "AccessDenied",
35
+ { permission: Schema.String },
36
+ { httpApiStatus: 403 },
37
+ ) {}
38
+
39
+ export type DeniedBy<P extends string> = Denied & { readonly permission: P };
40
+
41
+ export type DeniedBySchema<P extends string> = Schema.refine<DeniedBy<P>, typeof Denied>;
42
+
43
+ /** The refusals of one permission, from its id alone. */
44
+ export const deniedBy = <P extends string>(permission: P): DeniedBySchema<P> =>
45
+ Denied.pipe(Schema.refine((denied): denied is DeniedBy<P> => denied.permission === permission));
@@ -0,0 +1,311 @@
1
+ import type { Scope } from "effect";
2
+
3
+ import { Context, Effect, Layer, Predicate, Schema, SchemaTransformation } from "effect";
4
+
5
+ import * as Decision from "./Decision.ts";
6
+
7
+ export type Codec = Schema.Codec<unknown, unknown>;
8
+
9
+ export interface Config {
10
+ readonly requires?: unknown;
11
+ }
12
+
13
+ export interface Definition {
14
+ readonly id?: string;
15
+ readonly target?: Codec;
16
+ readonly error?: Codec;
17
+ }
18
+
19
+ export type IdOf<Key extends string, D extends Definition> = D extends {
20
+ readonly id: infer I extends string;
21
+ }
22
+ ? I
23
+ : Key;
24
+
25
+ export type Requires<C extends Config> = C extends { readonly requires: infer R } ? R : never;
26
+
27
+ export type TargetOf<D extends Definition> = D extends { readonly target: infer S extends Codec }
28
+ ? S["Type"]
29
+ : never;
30
+
31
+ export type TargetArgs<D extends Definition> = D extends { readonly target: Codec }
32
+ ? [target: TargetOf<D>]
33
+ : [];
34
+
35
+ export type PolicyError<D extends Definition> = D extends {
36
+ readonly error: infer S extends Codec;
37
+ }
38
+ ? S["Type"]
39
+ : never;
40
+
41
+ /** The errors a check can fail with: refusals of required permissions become decisions. */
42
+ export type CheckError<D extends Definition> = Exclude<PolicyError<D>, Refusal<D>>;
43
+
44
+ type Refusal<D extends Definition> = Extract<PolicyError<D>, Decision.Denied>;
45
+
46
+ export type Policy<D extends Definition, R> = (
47
+ ...target: TargetArgs<D>
48
+ ) => Decision.Decision | Effect.Effect<Decision.Decision, PolicyError<D>, R>;
49
+
50
+ export interface Api<I extends string, D extends Definition, R> {
51
+ readonly check: (...target: TargetArgs<D>) => Effect.Effect<Decision.Decision, CheckError<D>, R>;
52
+ readonly require: (
53
+ ...target: TargetArgs<D>
54
+ ) => Effect.Effect<void, Decision.DeniedBy<I> | PolicyError<D>, R>;
55
+ }
56
+
57
+ export interface Statics<Self, I extends string, D extends Definition, R> {
58
+ readonly id: I;
59
+ readonly definition: D;
60
+ readonly Denied: Decision.DeniedBySchema<I>;
61
+ readonly check: (
62
+ ...target: TargetArgs<D>
63
+ ) => Effect.Effect<Decision.Decision, CheckError<D>, Self | R>;
64
+ readonly require: (
65
+ ...target: TargetArgs<D>
66
+ ) => Effect.Effect<void, Decision.DeniedBy<I> | PolicyError<D>, Self | R>;
67
+ readonly toLayer: <E = never, RL = never>(
68
+ policy: Policy<D, R> | Effect.Effect<Policy<D, R>, E, RL>,
69
+ ) => Layer.Layer<Self, E, Exclude<RL, Scope.Scope>>;
70
+ }
71
+
72
+ export type Permission<Self, Key extends string, D extends Definition, R> = Context.ServiceClass<
73
+ Self,
74
+ Key,
75
+ Api<IdOf<Key, D>, D, R>
76
+ > &
77
+ Statics<Self, IdOf<Key, D>, D, R>;
78
+
79
+ export interface Any {
80
+ readonly id: string;
81
+ /** The service key: unique per permission, as Effect requires of service keys. */
82
+ readonly key: string;
83
+ }
84
+
85
+ export interface Tag<Self, I extends string, D extends Definition, R> extends Context.Service<
86
+ Self,
87
+ Api<I, D, R>
88
+ > {
89
+ readonly id: I;
90
+ readonly definition: D;
91
+ }
92
+
93
+ /**
94
+ * The keys of the permissions the caller may use, or undefined for all.
95
+ * Keys rather than ids, so permissions sharing an id stay apart.
96
+ */
97
+ export class Restriction extends Context.Reference<ReadonlySet<string> | undefined>(
98
+ "@kutur-kit/access/Permission/Restriction",
99
+ { defaultValue: () => undefined },
100
+ ) {}
101
+
102
+ export interface Decided<D extends Definition> {
103
+ readonly permission: string;
104
+ readonly via: "check" | "require";
105
+ readonly target: TargetArgs<D>;
106
+ readonly decision: Decision.Decision;
107
+ }
108
+
109
+ interface Internals<I extends string, D extends Definition, R> {
110
+ readonly key: string;
111
+ readonly id: I;
112
+ readonly decide: (
113
+ via: Decided<D>["via"],
114
+ target: ReadonlyArray<unknown>,
115
+ ) => Effect.Effect<Decided<D>, CheckError<D>, R>;
116
+ }
117
+
118
+ // True while a check runs, so requires inside its policies only answer it too.
119
+ const Answering = Context.Reference<boolean>("@kutur-kit/access/Permission/Answering", {
120
+ defaultValue: () => false,
121
+ });
122
+
123
+ // A WeakMap rather than a field keeps services from Layer.succeed valid.
124
+ const internals = new WeakMap<object, unknown>();
125
+
126
+ const make = <I extends string, D extends Definition, R>(
127
+ internal: Internals<I, D, R>,
128
+ ): Api<I, D, R> => {
129
+ const { key, id, decide } = internal;
130
+
131
+ const run = Effect.fnUntraced(function* (via: Decided<D>["via"], target: ReadonlyArray<unknown>) {
132
+ const answering = (yield* Answering) || via === "check";
133
+
134
+ const { decision } = yield* decide(answering ? "check" : via, target).pipe(
135
+ Effect.provideService(Answering, answering),
136
+ );
137
+
138
+ yield* Effect.annotateCurrentSpan({
139
+ "access.permission": id,
140
+ "access.decision": decision._tag,
141
+ ...(Predicate.isTagged(decision, "Deny") && {
142
+ "access.denied_by": decision.by,
143
+ ...(decision.reason !== undefined && { "access.reason": decision.reason }),
144
+ ...(decision.requirement !== undefined && { "access.requirement": decision.requirement }),
145
+ }),
146
+ });
147
+
148
+ return decision;
149
+ });
150
+
151
+ const check = Effect.fn(`${key}.check`)(function* (...target: ReadonlyArray<unknown>) {
152
+ return yield* run("check", target);
153
+ });
154
+
155
+ const require = Effect.fn(`${key}.require`)(function* (...target: ReadonlyArray<unknown>) {
156
+ const decision = yield* run("require", target);
157
+
158
+ // A required permission's refusal fails as that permission's, as it would
159
+ // without this one, so callers can still tell which refused.
160
+ return yield* Predicate.isTagged(decision, "Deny")
161
+ ? Effect.fail(new Decision.Denied({ permission: decision.requirement ?? id }))
162
+ : Effect.void;
163
+ });
164
+
165
+ // SAFETY: Callers pass the arguments TargetArgs<D> describes, and Denied
166
+ // carries this permission's id.
167
+ const service = { check, require } as Api<I, D, R>;
168
+
169
+ internals.set(service, internal);
170
+
171
+ return service;
172
+ };
173
+
174
+ /**
175
+ * Runs `f` after every decision of a permission service built by `toLayer`,
176
+ * inside the check's span and before `require` fails.
177
+ */
178
+ export const tap =
179
+ <D extends Definition>(f: (decided: Decided<D>) => Effect.Effect<void>) =>
180
+ <I extends string, R>(service: Api<I, D, R>): Api<I, D, R> => {
181
+ // SAFETY: make stored the Internals of this service's I, D and R.
182
+ const internal = internals.get(service) as Internals<I, D, R> | undefined;
183
+
184
+ if (internal === undefined)
185
+ throw new Error("Permission.tap needs a permission service built by toLayer");
186
+
187
+ return make({
188
+ ...internal,
189
+ decide: (via, target) => Effect.tap(internal.decide(via, target), f),
190
+ });
191
+ };
192
+
193
+ /** Whether the caller's restriction excludes a permission, so it must refuse. */
194
+ export const isRestricted = (permission: Any) =>
195
+ Effect.map(Restriction, (allowed) => allowed !== undefined && !allowed.has(permission.key));
196
+
197
+ export const restrictTo =
198
+ (permissions: Iterable<Any>) =>
199
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
200
+ Effect.flatMap(Restriction, (current) => {
201
+ const allowed = Array.from(permissions, ({ key }) => key);
202
+
203
+ return Effect.provideService(
204
+ effect,
205
+ Restriction,
206
+ new Set(current === undefined ? allowed : allowed.filter((key) => current.has(key))),
207
+ );
208
+ });
209
+
210
+ export interface PermissionSet<P extends Any> {
211
+ readonly schema: Schema.Codec<ReadonlyArray<P["id"]>, ReadonlyArray<string>>;
212
+ readonly restrictTo: (
213
+ ids: Iterable<P["id"]>,
214
+ ) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
215
+ }
216
+
217
+ export const set = <const Ps extends readonly [Any, ...ReadonlyArray<Any>]>(
218
+ permissions: Ps,
219
+ ): PermissionSet<Ps[number]> => {
220
+ type P = Ps[number];
221
+
222
+ const ids = permissions.map((permission): P["id"] => permission.id);
223
+ const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
224
+
225
+ if (duplicate !== undefined)
226
+ throw new Error(`Permission id ${duplicate} is used by more than one permission`);
227
+
228
+ // Ids name permissions outside the code, such as in stored token scopes.
229
+ const byId = new Map(permissions.map((permission) => [permission.id, permission]));
230
+ const isKnown = (id: string): id is P["id"] => byId.has(id);
231
+
232
+ const schema = Schema.Array(Schema.String).pipe(
233
+ Schema.decodeTo(
234
+ Schema.Array(Schema.Literals(ids)),
235
+ SchemaTransformation.transform<ReadonlyArray<P["id"]>, ReadonlyArray<string>>({
236
+ decode: (stored) => stored.filter(isKnown),
237
+ encode: (decoded) => decoded,
238
+ }),
239
+ ),
240
+ );
241
+
242
+ return {
243
+ schema,
244
+ restrictTo: (allowed) => restrictTo(Array.from(allowed).flatMap((id) => byId.get(id) ?? [])),
245
+ };
246
+ };
247
+
248
+ export const Service =
249
+ <Self, C extends Config = {}>() =>
250
+ <const Key extends string, const D extends Definition>(
251
+ key: Key,
252
+ definition: D,
253
+ ): Permission<Self, Key, D, Requires<C>> => {
254
+ type R = Requires<C>;
255
+
256
+ // SAFETY: IdOf<Key, D> is the declared id, or the key without one.
257
+ const id = (definition.id ?? key) as IdOf<Key, D>;
258
+
259
+ const Tag = Context.Service<Self, Api<IdOf<Key, D>, D, R>>()(key);
260
+
261
+ const restricted = isRestricted({ id, key });
262
+
263
+ const toLayer: Statics<Self, IdOf<Key, D>, D, R>["toLayer"] = (policy) =>
264
+ Layer.effect(
265
+ Tag,
266
+ Effect.gen(function* () {
267
+ // SAFETY: Callers pass the arguments TargetArgs<D> describes.
268
+ const resolved = (Effect.isEffect(policy) ? yield* policy : policy) as (
269
+ ...target: ReadonlyArray<unknown>
270
+ ) => Decision.Decision | Effect.Effect<Decision.Decision, PolicyError<D>, R>;
271
+
272
+ const decide: Internals<IdOf<Key, D>, D, R>["decide"] = Effect.fnUntraced(
273
+ function* (via, target) {
274
+ const result = (yield* restricted) ? Decision.restricted() : resolved(...target);
275
+
276
+ const refusal = (error: PolicyError<D>): error is Refusal<D> =>
277
+ Schema.is(Decision.Denied)(error);
278
+
279
+ const answered = (answer: Effect.Effect<Decision.Decision, PolicyError<D>, R>) =>
280
+ answer.pipe(
281
+ Effect.catchIf(refusal, (denied) =>
282
+ Effect.succeed(Decision.unmet(denied.permission)),
283
+ ),
284
+ );
285
+
286
+ const decision = Effect.isEffect(result) ? yield* answered(result) : result;
287
+
288
+ // SAFETY: Callers pass the arguments TargetArgs<D> describes.
289
+ return { permission: id, via, target: target as TargetArgs<D>, decision };
290
+ },
291
+ );
292
+
293
+ return Tag.of(make({ key, id, decide }));
294
+ }),
295
+ );
296
+
297
+ const check: Statics<Self, IdOf<Key, D>, D, R>["check"] = (...target) =>
298
+ Tag.use((permission) => permission.check(...target));
299
+
300
+ const require: Statics<Self, IdOf<Key, D>, D, R>["require"] = (...target) =>
301
+ Tag.use((permission) => permission.require(...target));
302
+
303
+ return Object.assign(Tag, {
304
+ id,
305
+ definition,
306
+ Denied: Decision.deniedBy(id),
307
+ toLayer,
308
+ check,
309
+ require,
310
+ });
311
+ };
@@ -0,0 +1,93 @@
1
+ import type { Scope } from "effect";
2
+
3
+ import { Context, Effect, Layer } from "effect";
4
+
5
+ import * as Condition from "./Condition.ts";
6
+ import * as Permission from "./Permission.ts";
7
+
8
+ export type Policy<D extends Permission.Definition, R> = (
9
+ where: Condition.Builder<Permission.TargetOf<D>>,
10
+ ) =>
11
+ | Condition.Condition<Permission.TargetOf<D>>
12
+ | Effect.Effect<Condition.Condition<Permission.TargetOf<D>>, Permission.CheckError<D>, R>;
13
+
14
+ export interface Api<D extends Permission.Definition, R> {
15
+ /** The targets the permission allows, or none outside the caller's restriction. */
16
+ readonly condition: Effect.Effect<
17
+ Condition.Condition<Permission.TargetOf<D>>,
18
+ Permission.CheckError<D>,
19
+ R
20
+ >;
21
+ }
22
+
23
+ export interface Statics<Self, D extends Permission.Definition, R> {
24
+ readonly condition: Effect.Effect<
25
+ Condition.Condition<Permission.TargetOf<D>>,
26
+ Permission.CheckError<D>,
27
+ Self | R
28
+ >;
29
+ readonly toLayer: <E = never, RL = never>(
30
+ policy: Policy<D, R> | Effect.Effect<Policy<D, R>, E, RL>,
31
+ ) => Layer.Layer<Self, E, Exclude<RL, Scope.Scope>>;
32
+ }
33
+
34
+ export type PermissionFilter<Self, D extends Permission.Definition, R> = Context.ServiceClass<
35
+ Self,
36
+ string,
37
+ Api<D, R>
38
+ > &
39
+ Statics<Self, D, R>;
40
+
41
+ /**
42
+ * Defines the query form of a permission: the condition its policy allows,
43
+ * for listing targets in one query. Its key is the permission's with
44
+ * `/Filter`, so name the class after the permission too, such as
45
+ * `ViewInvoiceFilter`. Its policy must agree with the permission's;
46
+ * `PermissionTesting.assertFilterAgrees` checks that.
47
+ */
48
+ export const Service =
49
+ <Self>() =>
50
+ <
51
+ PermissionSelf,
52
+ I extends string,
53
+ D extends Permission.Definition & { readonly target: Permission.Codec },
54
+ R,
55
+ >(
56
+ permission: Permission.Tag<PermissionSelf, I, D, R>,
57
+ ): PermissionFilter<Self, D, R> => {
58
+ const key = `${permission.key}/Filter`;
59
+ const Tag = Context.Service<Self, Api<D, R>>()(key);
60
+
61
+ const toLayer: Statics<Self, D, R>["toLayer"] = (policy) =>
62
+ Layer.effect(
63
+ Tag,
64
+ Effect.gen(function* () {
65
+ const resolved = Effect.isEffect(policy) ? yield* policy : policy;
66
+
67
+ const condition = Effect.gen(function* () {
68
+ const refused = yield* Permission.isRestricted(permission);
69
+
70
+ if (refused) return Condition.make<Permission.TargetOf<D>>().none();
71
+
72
+ const result = resolved(Condition.make());
73
+
74
+ return Effect.isEffect(result) ? yield* result : result;
75
+ }).pipe(
76
+ Effect.tap((result) =>
77
+ Effect.annotateCurrentSpan({
78
+ "access.permission": permission.id,
79
+ "access.condition": result._tag,
80
+ }),
81
+ ),
82
+ Effect.withSpan(`${key}.condition`),
83
+ );
84
+
85
+ return Tag.of({ condition });
86
+ }),
87
+ );
88
+
89
+ return Object.assign(Tag, {
90
+ condition: Tag.use((filter) => filter.condition),
91
+ toLayer,
92
+ });
93
+ };
package/src/audit.ts ADDED
@@ -0,0 +1,82 @@
1
+ import * as Audit from "@kutur-kit/audit/Audit";
2
+ import * as AuditEvent from "@kutur-kit/audit/AuditEvent";
3
+ import { Effect, Layer, Predicate, Schema } from "effect";
4
+
5
+ import * as Permission from "./Permission.ts";
6
+
7
+ /** What refused a denied decision. Allowed decisions have no details. */
8
+ export const Details = Schema.UndefinedOr(
9
+ Schema.Struct({
10
+ deniedBy: Schema.Literals(["restriction", "policy", "requirement"]),
11
+ requirement: Schema.optionalKey(Schema.String),
12
+ }),
13
+ );
14
+
15
+ type CheckView<D extends Permission.Definition, V> = [V] extends [never]
16
+ ? unknown
17
+ : [V] extends [Permission.Codec]
18
+ ? D extends { readonly target: Permission.Codec }
19
+ ? [Permission.TargetOf<D>] extends [V["Type"]]
20
+ ? unknown
21
+ : { readonly view: Schema.Codec<Permission.TargetOf<D>, unknown> }
22
+ : { readonly view?: false }
23
+ : unknown;
24
+
25
+ export interface Options<V> {
26
+ /** See `AuditEvent.Definition["view"]`. */
27
+ readonly view?: V;
28
+ }
29
+
30
+ /**
31
+ * Records the decisions of a permission's `require`s as audit events named by
32
+ * its id; `check`s only answer questions, so they are not recorded. Refusals
33
+ * fail the request whose transaction would store them, so writes are isolated.
34
+ */
35
+ export const record = <
36
+ Self,
37
+ I extends string,
38
+ D extends Permission.Definition,
39
+ R,
40
+ const V extends Permission.Codec | false = never,
41
+ >(
42
+ permission: Permission.Tag<Self, I, D, R>,
43
+ options?: Options<V> & CheckView<D, V>,
44
+ ) => {
45
+ const view = options?.view;
46
+
47
+ // Permissions without a target log Schema.Undefined, which records nothing.
48
+ const event = AuditEvent.make(permission.id, {
49
+ target: permission.definition.target ?? Schema.Undefined,
50
+ ...(view !== undefined && { view }),
51
+ outcomes: ["Allow", "Deny"] as const,
52
+ details: Details,
53
+ isolated: true,
54
+ });
55
+
56
+ return <E, RL>(layer: Layer.Layer<Self, E, RL>): Layer.Layer<Self, E, RL | Audit.AuditLog> =>
57
+ Layer.effect(
58
+ permission,
59
+ Effect.gen(function* () {
60
+ const inner = yield* permission;
61
+ const auditLog = yield* Audit.AuditLog;
62
+
63
+ return Permission.tap<D>(({ via, target: args, decision }) => {
64
+ if (via === "check") return Effect.void;
65
+
66
+ const denied = Predicate.isTagged(decision, "Deny") ? decision : undefined;
67
+
68
+ return event
69
+ .log({
70
+ target: args[0],
71
+ outcome: decision._tag,
72
+ ...(denied?.reason !== undefined && { reason: denied.reason }),
73
+ details: denied && {
74
+ deniedBy: denied.by,
75
+ ...(denied.requirement !== undefined && { requirement: denied.requirement }),
76
+ },
77
+ })
78
+ .pipe(Effect.provideService(Audit.AuditLog, auditLog));
79
+ })(inner);
80
+ }),
81
+ ).pipe(Layer.provide(layer));
82
+ };