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

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,108 @@
1
+ import type * as Condition from "../Condition.ts";
2
+
3
+ import { and, type Column, eq, isNull, or, type SQL, sql } from "drizzle-orm";
4
+ import { Match, Predicate } from "effect";
5
+
6
+ /**
7
+ * Column kinds whose SQL equality is JavaScript's, so a query selects exactly
8
+ * the rows Condition.matches accepts. Excluded: numeric (decimal text), char
9
+ * (space padded), string-mode bigints, citext and other custom types.
10
+ */
11
+ type Comparable =
12
+ | "string"
13
+ | "string enum"
14
+ | "string uuid"
15
+ | "number int16"
16
+ | "number int32"
17
+ | "number int53"
18
+ | "bigint int64"
19
+ | "boolean"
20
+ | "number float"
21
+ | "number double";
22
+
23
+ // char shares text's dataType and numeric's bigint mode shares bigint's, so
24
+ // the runtime check names column classes too.
25
+ const comparable: ReadonlySet<string> = new Set([
26
+ "PgText",
27
+ "PgVarchar",
28
+ "PgEnumColumn",
29
+ "PgEnumObjectColumn",
30
+ "PgUUID",
31
+ "PgSmallInt",
32
+ "PgInteger",
33
+ "PgBigInt53",
34
+ "PgBigInt64",
35
+ "PgBoolean",
36
+ "PgReal",
37
+ "PgDoublePrecision",
38
+ ]);
39
+
40
+ // PostgreSQL reads uuids in any case and writes them lowercase.
41
+ const canonicalUuid = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/;
42
+
43
+ /** A comparable column for every field a condition over `T` can compare; a Drizzle table qualifies. */
44
+ export type Columns<T> = {
45
+ readonly [K in Condition.Field<T>]: Column & { readonly dataType: Comparable };
46
+ };
47
+
48
+ /** Translates a condition, such as a PermissionFilter's, into a PostgreSQL `where` clause. */
49
+ export const where = <T>(columns: Columns<T>, condition: Condition.Condition<T>): SQL => {
50
+ const byField = new Map<string, Column>(Object.entries(columns));
51
+
52
+ const column = (field: string) => {
53
+ const found = byField.get(field);
54
+
55
+ if (found === undefined) throw new Error(`No column for condition field ${field}`);
56
+
57
+ if (!comparable.has(found.columnType))
58
+ throw new Error(
59
+ `Column ${field} (${found.getSQLType()}) compares differently in PostgreSQL than in JavaScript`,
60
+ );
61
+
62
+ return found;
63
+ };
64
+
65
+ const value = (target: Column, compared: Condition.Value) => {
66
+ if (
67
+ target.columnType === "PgUUID" &&
68
+ !(Predicate.isString(compared) && canonicalUuid.test(compared))
69
+ )
70
+ throw new Error(`Uuid ${String(compared)} is not lowercase and canonical`);
71
+
72
+ return compared;
73
+ };
74
+
75
+ const translate: (node: Condition.Node) => SQL = Match.type<Condition.Node>().pipe(
76
+ Match.tagsExhaustive({
77
+ All: () => sql`true`,
78
+ None: () => sql`false`,
79
+ Eq: ({ field, value: compared }) => {
80
+ const target = column(field);
81
+
82
+ return compared === null ? isNull(target) : eq(target, value(target, compared));
83
+ },
84
+ OneOf: ({ field, values }) => {
85
+ const target = column(field);
86
+ const known = values.filter((each) => each !== null).map((each) => value(target, each));
87
+
88
+ // One array parameter however long the list: @effect/sql-pg sends at
89
+ // most 32767 parameters per statement.
90
+ const listed =
91
+ known.length === 0
92
+ ? sql`false`
93
+ : sql`${target} = any(${sql.param(known)}::${sql.raw(target.getSQLType())}[])`;
94
+
95
+ // IN and ANY never match NULL, while Condition.matches finds null in the list.
96
+ return values.includes(null) ? (or(listed, isNull(target)) ?? listed) : listed;
97
+ },
98
+ And: ({ conditions }) => and(sql`true`, ...conditions.map(translate)) ?? sql`true`,
99
+ Or: ({ conditions }) => or(sql`false`, ...conditions.map(translate)) ?? sql`false`,
100
+ // Comparisons with NULL columns are NULL, which WHERE, AND and OR treat as
101
+ // false like Condition.matches does; NOT would keep it NULL, so negate with
102
+ // IS NOT TRUE instead.
103
+ Not: ({ condition: inner }) => sql`(${translate(inner)}) is not true`,
104
+ }),
105
+ );
106
+
107
+ return translate(condition);
108
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * as Condition from "./Condition.ts";
2
+
3
+ export * as Decision from "./Decision.ts";
4
+
5
+ export * as Permission from "./Permission.ts";
6
+
7
+ export * as PermissionFilter from "./PermissionFilter.ts";
@@ -0,0 +1,47 @@
1
+ import type * as Decision from "../Decision.ts";
2
+
3
+ import { Effect, Predicate } from "effect";
4
+
5
+ import * as Condition from "../Condition.ts";
6
+
7
+ export interface Checkable<T, E, R> {
8
+ readonly id: string;
9
+ readonly check: (target: T) => Effect.Effect<Decision.Decision, E, R>;
10
+ }
11
+
12
+ export interface Filter<T, E, R> {
13
+ readonly condition: Effect.Effect<Condition.Condition<T>, E, R>;
14
+ }
15
+
16
+ /**
17
+ * Dies unless a PermissionFilter's condition matches exactly the targets its
18
+ * permission allows, so lists never show what a direct read would refuse, or
19
+ * hide what it would allow.
20
+ */
21
+ export const assertFilterAgrees = Effect.fnUntraced(function* <
22
+ T extends object,
23
+ E,
24
+ RCheck,
25
+ RFilter,
26
+ >(permission: Checkable<T, E, RCheck>, filter: Filter<T, E, RFilter>, targets: Iterable<T>) {
27
+ const condition = yield* filter.condition;
28
+
29
+ const decided = yield* Effect.forEach(targets, (target) =>
30
+ Effect.map(permission.check(target), (decision) => ({
31
+ target,
32
+ allowed: Predicate.isTagged(decision, "Allow"),
33
+ })),
34
+ );
35
+
36
+ const mismatch = decided.find(
37
+ ({ target, allowed }) => allowed !== Condition.matches(condition, target),
38
+ );
39
+
40
+ return yield* mismatch === undefined
41
+ ? Effect.void
42
+ : Effect.die(
43
+ new Error(
44
+ `The filter of permission ${permission.id} ${mismatch.allowed ? "excludes" : "matches"} a target the permission ${mismatch.allowed ? "allows" : "refuses"}: ${JSON.stringify(mismatch.target)}`,
45
+ ),
46
+ );
47
+ });
@@ -0,0 +1 @@
1
+ export * as PermissionTesting from "./PermissionTesting.ts";