@suluk/hono 0.1.2 → 0.1.4
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.
- package/package.json +2 -2
- package/src/access.ts +67 -0
- package/src/index.ts +2 -0
- package/test/access.test.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/hono",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The Suluk derivation engine: minimal Hono+Zod RouteContracts in; v4 doc (dynamic per principal+time), validation, contract tests, and doc-coverage audit out. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.11",
|
|
23
23
|
"@suluk/zod": "^0.1.2",
|
|
24
24
|
"ajv": "^8.20.0",
|
|
25
25
|
"ajv-formats": "^3.0.1"
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ROW-LEVEL authorization engine that pairs with {@link enforceAccess} (the wire-level facet enforcer). A
|
|
3
|
+
* generic CRUD app declares each entity's access MODE; this maps mode → per-operation Rule → a decision: may the
|
|
4
|
+
* caller run it, and is the query SCOPED to their own rows. Pure (no Hono Context — takes the resolved
|
|
5
|
+
* `{ isAdmin, principal }`), so it is testable and reusable. `ruleToRequires` projects a Rule to the wire
|
|
6
|
+
* `requires` level so ONE declaration drives BOTH the CRUD gate AND the `x-suluk-access` facet/docs.
|
|
7
|
+
*/
|
|
8
|
+
import type { AccessRequires } from "./enforce";
|
|
9
|
+
|
|
10
|
+
/** A CRUD operation's authorization rule. */
|
|
11
|
+
export type Rule = "any" | "owner" | "admin" | "none";
|
|
12
|
+
/** The five CRUD operations' rules for one access mode. */
|
|
13
|
+
export interface Policy { list: Rule; get: Rule; create: Rule; update: Rule; delete: Rule }
|
|
14
|
+
/** The built-in access modes (a sensible SaaS/commerce default set; override via `policyFor`'s `policies` arg). */
|
|
15
|
+
export type AccessMode = "public" | "admin" | "submit" | "owned" | "ownedAppend" | "ownedReadonly" | "review";
|
|
16
|
+
|
|
17
|
+
/** The opt-in default mode→policy preset. Adopt by reference, or pass your own matrix to {@link policyFor}. */
|
|
18
|
+
export const DEFAULT_POLICIES: Record<AccessMode, Policy> = {
|
|
19
|
+
// catalog + content: world-readable, admin-writable
|
|
20
|
+
public: { list: "any", get: "any", create: "admin", update: "admin", delete: "admin" },
|
|
21
|
+
// sensitive (e.g. discount codes): admin-only — even reads (listing all is a leak)
|
|
22
|
+
admin: { list: "admin", get: "admin", create: "admin", update: "admin", delete: "admin" },
|
|
23
|
+
// public submissions (contact, newsletter): anyone may create; only admins read/modify
|
|
24
|
+
submit: { list: "admin", get: "admin", create: "any", update: "admin", delete: "admin" },
|
|
25
|
+
// user-owned: each caller only sees/mutates their own rows (admin sees all)
|
|
26
|
+
owned: { list: "owner", get: "owner", create: "owner", update: "owner", delete: "owner" },
|
|
27
|
+
// owned + append-only to the user — place + read your own, but only the system/admin mutates (no self-mark-paid)
|
|
28
|
+
ownedAppend: { list: "owner", get: "owner", create: "owner", update: "admin", delete: "admin" },
|
|
29
|
+
// owned but READ-ONLY to the user — the system/admin even creates it (e.g. billing rows)
|
|
30
|
+
ownedReadonly: { list: "owner", get: "owner", create: "admin", update: "admin", delete: "admin" },
|
|
31
|
+
// public-read, owner-write (e.g. product reviews): everyone reads; you only edit your own
|
|
32
|
+
review: { list: "any", get: "any", create: "owner", update: "owner", delete: "owner" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The policy for an access mode (default: owned when an ownerCol is present, else public). `policies` overrides the preset. */
|
|
36
|
+
export function policyFor(access: AccessMode | undefined, ownerCol?: string, policies: Record<AccessMode, Policy> = DEFAULT_POLICIES): Policy {
|
|
37
|
+
return policies[access ?? (ownerCol ? "owned" : "public")];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The resolved caller identity a gate decision needs (compute from your Hono Context: isAdmin flag + principal id). */
|
|
41
|
+
export interface GateIdentity { isAdmin: boolean; principal: string | null }
|
|
42
|
+
/** A gate decision: may the op run, scope the query to the owner, and — when denied — the honest status. */
|
|
43
|
+
export interface GateDecision { ok: boolean; scopeOwner: boolean; status?: 401 | 403 }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decide whether a caller may run an op (per the rule), whether to scope the query to their own rows, and the honest
|
|
47
|
+
* deny status. FAIL-CLOSED: an `owner` op with no principal is 401 (the wire must enforce what `x-suluk-access`
|
|
48
|
+
* claims — a null-scoped empty 200 would let the facet lie); `admin` with no principal is 401, signed-in-non-admin is
|
|
49
|
+
* 403; `none` hard-denies 403. A signed-in owner is scoped to their rows; an admin sees all.
|
|
50
|
+
*/
|
|
51
|
+
export function gate(rule: Rule, id: GateIdentity): GateDecision {
|
|
52
|
+
switch (rule) {
|
|
53
|
+
case "any": return { ok: true, scopeOwner: false };
|
|
54
|
+
case "owner":
|
|
55
|
+
if (id.isAdmin) return { ok: true, scopeOwner: false }; // admin sees all
|
|
56
|
+
if (!id.principal) return { ok: false, scopeOwner: false, status: 401 }; // owner op needs a verified caller
|
|
57
|
+
return { ok: true, scopeOwner: true }; // signed-in: scoped to own rows
|
|
58
|
+
case "admin":
|
|
59
|
+
if (!id.principal) return { ok: false, scopeOwner: false, status: 401 }; // authenticate first (RFC 7235)
|
|
60
|
+
return id.isAdmin ? { ok: true, scopeOwner: false } : { ok: false, scopeOwner: false, status: 403 }; // signed-in non-admin → 403
|
|
61
|
+
default: return { ok: false, scopeOwner: false, status: 403 };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Project a CRUD Rule to the wire-level `requires` (so one rule drives BOTH the gate AND the x-suluk-access facet). */
|
|
66
|
+
const RULE_TO_REQUIRES: Record<Rule, AccessRequires> = { any: "anyone", owner: "authenticated", admin: "admin", none: "admin" };
|
|
67
|
+
export function ruleToRequires(rule: Rule): AccessRequires { return RULE_TO_REQUIRES[rule]; }
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { contractChecks, runContractChecks, type Check, type CheckRun } from "./
|
|
|
10
10
|
export { validateSchema2020, type SchemaCheck } from "./schema-check";
|
|
11
11
|
export { mount } from "./mount";
|
|
12
12
|
export { enforceAccess, createGuard, type EnforceAccessConfig, type IdentityConfig, type Guard, type AccessFacet, type AccessRequires } from "./enforce";
|
|
13
|
+
// the row-level CRUD authorization engine (mode→policy→rule→decision + owner-scoping) that pairs with enforceAccess.
|
|
14
|
+
export { gate, policyFor, ruleToRequires, DEFAULT_POLICIES, type Rule, type Policy, type AccessMode, type GateIdentity, type GateDecision } from "./access";
|
|
13
15
|
export { SulukHttpError, HttpErrors, type SulukHttpErrorInit } from "./errors";
|
|
14
16
|
export { onError, type OnErrorOptions } from "./on-error";
|
|
15
17
|
export {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row-level CRUD authorization engine — gate() decisions (fail-closed 401/403 + owner-scoping), policyFor
|
|
3
|
+
* preset resolution + override, and ruleToRequires (the rule→wire-requires projection that keeps the CRUD gate and
|
|
4
|
+
* the x-suluk-access facet in lockstep).
|
|
5
|
+
*/
|
|
6
|
+
import { test, expect, describe } from "bun:test";
|
|
7
|
+
import { gate, policyFor, ruleToRequires, DEFAULT_POLICIES, type Rule } from "../src/index";
|
|
8
|
+
|
|
9
|
+
describe("gate", () => {
|
|
10
|
+
test("any → open, no scope", () => {
|
|
11
|
+
expect(gate("any", { isAdmin: false, principal: null })).toEqual({ ok: true, scopeOwner: false });
|
|
12
|
+
});
|
|
13
|
+
test("owner: anon → 401, signed-in → scoped, admin → all", () => {
|
|
14
|
+
expect(gate("owner", { isAdmin: false, principal: null })).toEqual({ ok: false, scopeOwner: false, status: 401 });
|
|
15
|
+
expect(gate("owner", { isAdmin: false, principal: "u1" })).toEqual({ ok: true, scopeOwner: true });
|
|
16
|
+
expect(gate("owner", { isAdmin: true, principal: "u1" })).toEqual({ ok: true, scopeOwner: false }); // admin sees all
|
|
17
|
+
});
|
|
18
|
+
test("admin: anon → 401 (authenticate first), signed-in-non-admin → 403, admin → ok", () => {
|
|
19
|
+
expect(gate("admin", { isAdmin: false, principal: null })).toEqual({ ok: false, scopeOwner: false, status: 401 });
|
|
20
|
+
expect(gate("admin", { isAdmin: false, principal: "u1" })).toEqual({ ok: false, scopeOwner: false, status: 403 });
|
|
21
|
+
expect(gate("admin", { isAdmin: true, principal: "u1" })).toEqual({ ok: true, scopeOwner: false });
|
|
22
|
+
});
|
|
23
|
+
test("none → hard 403", () => {
|
|
24
|
+
expect(gate("none", { isAdmin: true, principal: "u1" })).toEqual({ ok: false, scopeOwner: false, status: 403 });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("policyFor", () => {
|
|
29
|
+
test("resolves modes, defaults owned-with-ownerCol / public-without, honors an override matrix", () => {
|
|
30
|
+
expect(policyFor("owned").update).toBe("owner");
|
|
31
|
+
expect(policyFor("ownedAppend").update).toBe("admin"); // no self-mutate
|
|
32
|
+
expect(policyFor(undefined, "customerId")).toEqual(DEFAULT_POLICIES.owned);
|
|
33
|
+
expect(policyFor(undefined)).toEqual(DEFAULT_POLICIES.public);
|
|
34
|
+
const custom = { ...DEFAULT_POLICIES, public: { ...DEFAULT_POLICIES.public, list: "admin" as Rule } };
|
|
35
|
+
expect(policyFor("public", undefined, custom).list).toBe("admin");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("ruleToRequires", () => {
|
|
40
|
+
test("projects a CRUD rule to the wire requires level", () => {
|
|
41
|
+
expect(ruleToRequires("any")).toBe("anyone");
|
|
42
|
+
expect(ruleToRequires("owner")).toBe("authenticated");
|
|
43
|
+
expect(ruleToRequires("admin")).toBe("admin");
|
|
44
|
+
expect(ruleToRequires("none")).toBe("admin");
|
|
45
|
+
});
|
|
46
|
+
});
|