rastack 0.0.58 → 0.0.59
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/CHANGELOG.md +9 -0
- package/README.md +190 -0
- package/dist/deploy/ci.js +2 -2
- package/dist/deploy/io.js +2 -2
- package/dist/dev/harness.d.ts +1 -1
- package/dist/dev/harness.js +1 -2
- package/dist/external/cache.d.ts +28 -0
- package/dist/external/cache.js +35 -0
- package/dist/external/envelope.d.ts +35 -0
- package/dist/external/envelope.js +48 -0
- package/dist/external/graphql.d.ts +25 -0
- package/dist/external/graphql.js +78 -0
- package/dist/external/handler.d.ts +52 -0
- package/dist/external/handler.js +139 -0
- package/dist/external/index.d.ts +19 -0
- package/dist/external/index.js +38 -0
- package/dist/external/lambda.d.ts +49 -0
- package/dist/external/lambda.js +86 -0
- package/dist/external/rest.d.ts +17 -0
- package/dist/external/rest.js +46 -0
- package/dist/external/route.d.ts +12 -0
- package/dist/external/route.js +33 -0
- package/dist/external/service.d.ts +50 -0
- package/dist/external/service.js +59 -0
- package/dist/external/types.d.ts +107 -0
- package/dist/external/types.js +22 -0
- package/dist/external/util.d.ts +16 -0
- package/dist/external/util.js +58 -0
- package/dist/rastack-dev.js +10 -0
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +6 -6
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/external.ts +10 -0
- package/hooks/query/delete.ts +12 -11
- package/hooks/query/fetch.ts +6 -8
- package/hooks/query/interfaces.ts +1 -4
- package/hooks/query/list.ts +16 -9
- package/hooks/query/update.ts +8 -6
- package/package.json +24 -2
- package/scripts/copy-templates.js +1 -1
- package/scripts/copy-wasm.js +1 -1
- package/scripts/dev-install.mjs +63 -0
- package/src/deploy/ci.ts +2 -2
- package/src/deploy/io.ts +2 -2
- package/src/dev/harness.ts +1 -2
- package/src/external/cache.ts +53 -0
- package/src/external/envelope.ts +78 -0
- package/src/external/graphql.ts +98 -0
- package/src/external/handler.ts +207 -0
- package/src/external/index.ts +25 -0
- package/src/external/lambda.ts +108 -0
- package/src/external/rest.ts +60 -0
- package/src/external/route.ts +33 -0
- package/src/external/service.ts +86 -0
- package/src/external/types.ts +121 -0
- package/src/external/util.ts +57 -0
- package/src/rastack-dev.ts +11 -0
- package/src/rastack-wasm-build.ts +6 -6
- package/jest.config.cjs +0 -12
- package/test/auth.spec.ts +0 -207
- package/test/cache.spec.ts +0 -263
- package/test/cognito.spec.ts +0 -281
- package/test/compile.spec.ts +0 -240
- package/test/components.spec.ts +0 -524
- package/test/csv-schema.spec.ts +0 -143
- package/test/deploy.spec.ts +0 -571
- package/test/dev.spec.ts +0 -414
- package/test/entities.spec.ts +0 -597
- package/test/import.spec.ts +0 -241
- package/test/plugin.spec.ts +0 -315
- package/test/runtime-manifest.spec.ts +0 -309
- package/test/schema-entities.spec.ts +0 -688
- package/test/tokens.spec.ts +0 -302
- package/test/transitions.spec.ts +0 -372
- package/test/typed-hooks.spec.ts +0 -412
- package/test/update.spec.ts +0 -152
- package/test/validate.spec.ts +0 -319
- package/tsconfig.json +0 -27
- package/wasm/package.json +0 -4
- package/wasm/rastack_wasm.d.ts +0 -100
- package/wasm/rastack_wasm.js +0 -745
- package/wasm/rastack_wasm_bg.wasm +0 -0
- package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External-API bindings — how a rastack `app.model` is served from an upstream
|
|
3
|
+
* REST or GraphQL API.
|
|
4
|
+
*
|
|
5
|
+
* The stack's own resources are served by the Rust API / WASM engine over the
|
|
6
|
+
* `/api/{app}/v1/{model}/` contract. An **external** resource (a GitHub repo, an
|
|
7
|
+
* Expo project, an AWS stack) is served by the proxy instead: it speaks the same
|
|
8
|
+
* contract to the client, and translates each request into an upstream call,
|
|
9
|
+
* normalises the response into rastack's DRF-shaped envelope, and caches it.
|
|
10
|
+
*
|
|
11
|
+
* One {@link ResourceBinding} describes that translation for a single model. The
|
|
12
|
+
* proxy core ({@link ./handler}) is a pure function of a request + the bindings +
|
|
13
|
+
* an injected `fetch`/cache, so the *same* logic runs in the deployed Lambda and
|
|
14
|
+
* in the mini service inside `rastack dev`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A minimal `fetch` surface — the global `fetch` satisfies it; tests fake it. */
|
|
18
|
+
export interface FetchResponse {
|
|
19
|
+
status: number;
|
|
20
|
+
headers: { get(name: string): string | null };
|
|
21
|
+
json(): Promise<unknown>;
|
|
22
|
+
text(): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
export type FetchLike = (
|
|
25
|
+
url: string,
|
|
26
|
+
init?: {
|
|
27
|
+
method?: string;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
body?: string;
|
|
30
|
+
},
|
|
31
|
+
) => Promise<FetchResponse>;
|
|
32
|
+
|
|
33
|
+
/** Common binding fields, independent of transport. */
|
|
34
|
+
interface BindingBase {
|
|
35
|
+
/** The rastack app namespace, e.g. `"github"`. */
|
|
36
|
+
app: string;
|
|
37
|
+
/** The rastack model, the `{model}` in `/api/{app}/v1/{model}/`. */
|
|
38
|
+
model: string;
|
|
39
|
+
/** The upstream field that is the row's identity. Default `"id"`. */
|
|
40
|
+
primaryKey?: string;
|
|
41
|
+
/** Cache TTL in ms for this resource's responses. Default 60_000. */
|
|
42
|
+
ttlMs?: number;
|
|
43
|
+
/** Whitelist of upstream fields to project into each row (default: all). */
|
|
44
|
+
fields?: string[];
|
|
45
|
+
/** Static headers merged into every upstream request (auth is separate). */
|
|
46
|
+
headers?: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Serve a model from an upstream REST endpoint. */
|
|
50
|
+
export interface RestBinding extends BindingBase {
|
|
51
|
+
transport: "rest";
|
|
52
|
+
/** Upstream origin, e.g. `"https://api.github.com"`. */
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
/** Path to the list, e.g. `"/user/repos"`. */
|
|
55
|
+
listPath: string;
|
|
56
|
+
/** Path to a detail; `{id}` is substituted, e.g. `"/repositories/{id}"`. */
|
|
57
|
+
detailPath?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Map rastack list params (`page`, `page_size`, `search`, `order_by`) to the
|
|
60
|
+
* upstream's own names (`page`, `per_page`, `q`, `sort`). Unmapped params are
|
|
61
|
+
* dropped, so the upstream never sees rastack-only query keys.
|
|
62
|
+
*/
|
|
63
|
+
queryMap?: Partial<Record<"page" | "page_size" | "search" | "order_by", string>>;
|
|
64
|
+
/** Dot path to the array inside the list response (default: the body *is* the array). */
|
|
65
|
+
itemsPath?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Serve a model from an upstream GraphQL endpoint. */
|
|
69
|
+
export interface GraphQLBinding extends BindingBase {
|
|
70
|
+
transport: "graphql";
|
|
71
|
+
/** The GraphQL endpoint URL, e.g. `"https://api.expo.dev/graphql"`. */
|
|
72
|
+
baseUrl: string;
|
|
73
|
+
/** The list query. Variables are filled from {@link variableMap}. */
|
|
74
|
+
listQuery: string;
|
|
75
|
+
/** Dot path into `data` to the array, e.g. `"me.accounts.0.apps"`. */
|
|
76
|
+
itemsPath: string;
|
|
77
|
+
/** Optional detail query; `$id` variable is bound to the requested id. */
|
|
78
|
+
detailQuery?: string;
|
|
79
|
+
/** Dot path into `data` to the detail object. */
|
|
80
|
+
detailPath?: string;
|
|
81
|
+
/** Map rastack params to GraphQL variable names (e.g. `page_size` → `first`). */
|
|
82
|
+
variableMap?: Partial<Record<"page" | "page_size" | "search" | "order_by" | "id", string>>;
|
|
83
|
+
/** Static variables merged into every query. */
|
|
84
|
+
variables?: Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Serve a model from an injected resolver function rather than a plain HTTP
|
|
89
|
+
* call — for upstreams that need an SDK/signing the proxy core shouldn't import
|
|
90
|
+
* (AWS SigV4, say). The named resolver is provided via `HandlerDeps.resolvers`,
|
|
91
|
+
* so the framework stays dependency-free while the app plugs in, e.g., an AWS
|
|
92
|
+
* CloudFormation resolver. Still consumed through the normal `useData` contract.
|
|
93
|
+
*/
|
|
94
|
+
export interface ResolverBinding extends BindingBase {
|
|
95
|
+
transport: "resolver";
|
|
96
|
+
/** Key into `HandlerDeps.resolvers` for the function that produces the data. */
|
|
97
|
+
resolver: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ResourceBinding = RestBinding | GraphQLBinding | ResolverBinding;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A resolver produces the upstream data for a {@link ResolverBinding}: an array
|
|
104
|
+
* for a list request, or a single object for a detail request. Normalisation
|
|
105
|
+
* into the rastack envelope is done by the core, exactly as for HTTP transports.
|
|
106
|
+
*/
|
|
107
|
+
export type ResourceResolver = (args: {
|
|
108
|
+
app: string;
|
|
109
|
+
model: string;
|
|
110
|
+
id?: string;
|
|
111
|
+
query: Record<string, string[]>;
|
|
112
|
+
}) => Promise<unknown>;
|
|
113
|
+
|
|
114
|
+
/** Look a binding up by `app`/`model`. */
|
|
115
|
+
export function findBinding(
|
|
116
|
+
bindings: ResourceBinding[],
|
|
117
|
+
app: string,
|
|
118
|
+
model: string,
|
|
119
|
+
): ResourceBinding | undefined {
|
|
120
|
+
return bindings.find((b) => b.app === app && b.model === model);
|
|
121
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Small pure helpers shared by the proxy core. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a value at a dot path (`"me.accounts.0.apps"`) out of a JSON value.
|
|
5
|
+
* Numeric segments index arrays. Returns `undefined` for any missing hop, so
|
|
6
|
+
* callers can default gracefully rather than throw on a shape they didn't get.
|
|
7
|
+
*/
|
|
8
|
+
export function getPath(value: unknown, path: string | undefined): unknown {
|
|
9
|
+
if (!path) return value;
|
|
10
|
+
let cur: unknown = value;
|
|
11
|
+
for (const segment of path.split(".")) {
|
|
12
|
+
if (cur == null) return undefined;
|
|
13
|
+
if (Array.isArray(cur)) {
|
|
14
|
+
const idx = Number(segment);
|
|
15
|
+
cur = Number.isInteger(idx) ? cur[idx] : undefined;
|
|
16
|
+
} else if (typeof cur === "object") {
|
|
17
|
+
cur = (cur as Record<string, unknown>)[segment];
|
|
18
|
+
} else {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return cur;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep only `fields` on a row (plus always its `id`). No-op when unset. */
|
|
26
|
+
export function projectFields(
|
|
27
|
+
row: Record<string, unknown>,
|
|
28
|
+
fields: string[] | undefined,
|
|
29
|
+
): Record<string, unknown> {
|
|
30
|
+
if (!fields || !fields.length) return row;
|
|
31
|
+
const out: Record<string, unknown> = {};
|
|
32
|
+
if ("id" in row) out.id = row.id;
|
|
33
|
+
for (const f of fields) if (f in row) out[f] = row[f];
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure a row carries an `id` (the rastack detail-URL key). If the upstream's
|
|
39
|
+
* identity lives under a different `primaryKey`, mirror it onto `id`.
|
|
40
|
+
*/
|
|
41
|
+
export function ensureId(
|
|
42
|
+
row: Record<string, unknown>,
|
|
43
|
+
primaryKey: string,
|
|
44
|
+
): Record<string, unknown> {
|
|
45
|
+
if (primaryKey !== "id" && row.id === undefined && row[primaryKey] !== undefined) {
|
|
46
|
+
return { ...row, id: row[primaryKey] };
|
|
47
|
+
}
|
|
48
|
+
return row;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** First value for a key in a parsed query (`{ page: ["2"] }` → `"2"`). */
|
|
52
|
+
export function firstParam(
|
|
53
|
+
query: Record<string, string[]>,
|
|
54
|
+
key: string,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
return query[key]?.[0];
|
|
57
|
+
}
|
package/src/rastack-dev.ts
CHANGED
|
@@ -321,6 +321,17 @@ async function runMetroMode(args: DevArgs, cwd: string): Promise<void> {
|
|
|
321
321
|
server.on("upgrade", (req, socket, head) =>
|
|
322
322
|
proxyUpgrade(req, socket as net.Socket, head, metroPort),
|
|
323
323
|
);
|
|
324
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
325
|
+
if (err.code === "EADDRINUSE") {
|
|
326
|
+
console.error(
|
|
327
|
+
`\n ✗ Port ${args.port} is already in use — is \`rastack dev\` already running?\n` +
|
|
328
|
+
` Stop it, or pick another port: rastack dev --port <n>\n`,
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
console.error(`\n ✗ dev server error: ${err.message}`);
|
|
332
|
+
}
|
|
333
|
+
stop(1);
|
|
334
|
+
});
|
|
324
335
|
|
|
325
336
|
server.listen(args.port, () => {
|
|
326
337
|
const url = `http://localhost:${args.port}`;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Pipeline:
|
|
8
8
|
* 1. cargo build -p rastack-wasm --release --target wasm32-unknown-unknown
|
|
9
|
-
* 2. wasm-bindgen --target web →
|
|
9
|
+
* 2. wasm-bindgen --target web → wasm/rastack_wasm.js + rastack_wasm_bg.wasm
|
|
10
10
|
* 3. wasm-opt -Oz (if available) to shrink the binary
|
|
11
11
|
*
|
|
12
12
|
* This is invoked by the release CI (to bundle a prebuilt engine into the npm
|
|
@@ -20,9 +20,9 @@ import { spawnSync } from "child_process";
|
|
|
20
20
|
import fs from "fs";
|
|
21
21
|
import path from "path";
|
|
22
22
|
|
|
23
|
-
const repoRoot = path.resolve(__dirname, ".."
|
|
24
|
-
const rustDir = path.join(repoRoot, "
|
|
25
|
-
const outDir = path.join(repoRoot, "
|
|
23
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
24
|
+
const rustDir = path.join(repoRoot, "engine");
|
|
25
|
+
const outDir = path.join(repoRoot, "wasm");
|
|
26
26
|
const wasmPath = path.join(
|
|
27
27
|
rustDir,
|
|
28
28
|
"target",
|
|
@@ -78,7 +78,7 @@ export function buildWasm(): void {
|
|
|
78
78
|
rustDir,
|
|
79
79
|
);
|
|
80
80
|
|
|
81
|
-
console.log("→ wasm-bindgen →
|
|
81
|
+
console.log("→ wasm-bindgen → wasm/");
|
|
82
82
|
run("wasm-bindgen", [
|
|
83
83
|
"--target",
|
|
84
84
|
"web",
|
|
@@ -105,7 +105,7 @@ export function buildWasm(): void {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const size = fs.statSync(path.join(outDir, "rastack_wasm_bg.wasm")).size;
|
|
108
|
-
console.log(`✓ wasm engine built:
|
|
108
|
+
console.log(`✓ wasm engine built: wasm/ (${(size / 1024).toFixed(0)} KB)`);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
if (require.main === module) {
|
package/jest.config.cjs
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
module.exports = {
|
|
2
|
-
preset: 'ts-jest',
|
|
3
|
-
testEnvironment: 'node',
|
|
4
|
-
testMatch: ['**/test/**/*.spec.ts'],
|
|
5
|
-
moduleFileExtensions: ['ts', 'js'],
|
|
6
|
-
// Let executed test fixtures import the package by its published subpath
|
|
7
|
-
// names, the same way a consumer would (the resource compiler resolves these
|
|
8
|
-
// via its own ts.Program; these mappings are for code jest actually runs).
|
|
9
|
-
moduleNameMapper: {
|
|
10
|
-
'^rastack/tokens$': '<rootDir>/tokens.ts',
|
|
11
|
-
},
|
|
12
|
-
};
|
package/test/auth.spec.ts
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the `rastack/auth` core — the client-side mirror of the access
|
|
3
|
-
* rules `rastack-api-core` enforces (see `rust/rastack-api-core/tests/access.rs`
|
|
4
|
-
* for the server side of the same matrix). The semantics here must track the
|
|
5
|
-
* Rust `check_permission` / `enforced_owner` exactly: `can()` answers "would
|
|
6
|
-
* the server accept this request?", so the components can limit the paths they
|
|
7
|
-
* expose instead of surfacing 401s.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
anonymousAuth,
|
|
12
|
-
authFromClaims,
|
|
13
|
-
bypassesRls,
|
|
14
|
-
can,
|
|
15
|
-
canActOnRow,
|
|
16
|
-
isAdmin,
|
|
17
|
-
ownsRow,
|
|
18
|
-
policyFromManifest,
|
|
19
|
-
policyFromOpenApi,
|
|
20
|
-
trustedAuth,
|
|
21
|
-
userAuth,
|
|
22
|
-
type ResourcePolicy,
|
|
23
|
-
} from "../auth/core";
|
|
24
|
-
|
|
25
|
-
const rls: ResourcePolicy = {
|
|
26
|
-
permission: "authenticated",
|
|
27
|
-
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
describe("rastack/auth — the permission gate (can)", () => {
|
|
31
|
-
it("defaults to authenticated — secure by default, like the server", () => {
|
|
32
|
-
expect(can(anonymousAuth(), {}, "list")).toBe(false);
|
|
33
|
-
expect(can(anonymousAuth(), undefined, "create")).toBe(false);
|
|
34
|
-
expect(can(userAuth("u1"), {}, "list")).toBe(true);
|
|
35
|
-
expect(can(userAuth("u1"), undefined, "delete")).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("public admits everyone, for every action", () => {
|
|
39
|
-
const policy: ResourcePolicy = { permission: "public" };
|
|
40
|
-
for (const action of [
|
|
41
|
-
"list",
|
|
42
|
-
"retrieve",
|
|
43
|
-
"create",
|
|
44
|
-
"update",
|
|
45
|
-
"delete",
|
|
46
|
-
] as const) {
|
|
47
|
-
expect(can(anonymousAuth(), policy, action)).toBe(true);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("authenticatedOrReadOnly admits anonymous reads but not writes", () => {
|
|
52
|
-
const policy: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
|
|
53
|
-
expect(can(anonymousAuth(), policy, "list")).toBe(true);
|
|
54
|
-
expect(can(anonymousAuth(), policy, "retrieve")).toBe(true);
|
|
55
|
-
expect(can(anonymousAuth(), policy, "create")).toBe(false);
|
|
56
|
-
expect(can(anonymousAuth(), policy, "update")).toBe(false);
|
|
57
|
-
expect(can(anonymousAuth(), policy, "delete")).toBe(false);
|
|
58
|
-
expect(can(userAuth("u1"), policy, "delete")).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("row-secured resources need a subject (the server answers 401 without one)", () => {
|
|
62
|
-
// Authenticated but subject-less — e.g. a machine token without `sub`.
|
|
63
|
-
const noSub = { authenticated: true, groups: [] };
|
|
64
|
-
expect(can(noSub, rls, "list")).toBe(false);
|
|
65
|
-
expect(can(userAuth("u1"), rls, "list")).toBe(true);
|
|
66
|
-
// A configured admin group bypasses row-level security.
|
|
67
|
-
expect(can({ ...noSub, groups: ["staff"] }, rls, "list")).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("trusted contexts (dev without auth) pass everything", () => {
|
|
71
|
-
expect(can(trustedAuth(), rls, "delete")).toBe(true);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("a missing identity gates nothing — the server stays the enforcer", () => {
|
|
75
|
-
expect(can(undefined, rls, "delete")).toBe(true);
|
|
76
|
-
expect(can(null, { permission: "authenticated" }, "create")).toBe(true);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe("rastack/auth — row-level security helpers", () => {
|
|
81
|
-
const mine = { id: 1, owner: "u1" };
|
|
82
|
-
const theirs = { id: 2, owner: "u2" };
|
|
83
|
-
|
|
84
|
-
it("owners own their rows; others don't", () => {
|
|
85
|
-
expect(ownsRow(userAuth("u1"), rls, mine)).toBe(true);
|
|
86
|
-
expect(ownsRow(userAuth("u1"), rls, theirs)).toBe(false);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("admin groups and trusted contexts bypass the owner filter", () => {
|
|
90
|
-
expect(bypassesRls(userAuth("u1", ["staff"]), rls)).toBe(true);
|
|
91
|
-
expect(ownsRow(userAuth("u1", ["staff"]), rls, theirs)).toBe(true);
|
|
92
|
-
expect(ownsRow(trustedAuth(), rls, theirs)).toBe(true);
|
|
93
|
-
expect(bypassesRls(userAuth("u1", ["other"]), rls)).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("unsecured resources are owned by everyone", () => {
|
|
97
|
-
expect(ownsRow(userAuth("u1"), {}, theirs)).toBe(true);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("canActOnRow combines the gate and ownership", () => {
|
|
101
|
-
expect(canActOnRow(userAuth("u1"), rls, "update", mine)).toBe(true);
|
|
102
|
-
expect(canActOnRow(userAuth("u1"), rls, "update", theirs)).toBe(false);
|
|
103
|
-
expect(canActOnRow(anonymousAuth(), rls, "update", mine)).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe("rastack/auth — group-owned datasets", () => {
|
|
108
|
-
// A group dataset: rows belong to a team, physically partitioned per group.
|
|
109
|
-
const grouped: ResourcePolicy = {
|
|
110
|
-
permission: "authenticated",
|
|
111
|
-
access: { groupField: "team", scope: "group", adminGroups: ["staff"] },
|
|
112
|
-
};
|
|
113
|
-
const blueRow = { id: 1, team: "blue" };
|
|
114
|
-
|
|
115
|
-
it("members read; identities in no group can read (empty) but never write", () => {
|
|
116
|
-
const dana = userAuth("dana", ["blue"]);
|
|
117
|
-
const loner = userAuth("lonnie", []);
|
|
118
|
-
expect(can(dana, grouped, "list")).toBe(true);
|
|
119
|
-
expect(can(dana, grouped, "create")).toBe(true);
|
|
120
|
-
// Reads outside any group just come back empty — but a write can never
|
|
121
|
-
// succeed, so the UI shouldn't offer it.
|
|
122
|
-
expect(can(loner, grouped, "list")).toBe(true);
|
|
123
|
-
expect(can(loner, grouped, "create")).toBe(false);
|
|
124
|
-
expect(can(loner, grouped, "delete")).toBe(false);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("row membership mirrors the server's group guard", () => {
|
|
128
|
-
expect(ownsRow(userAuth("dana", ["blue"]), grouped, blueRow)).toBe(true);
|
|
129
|
-
expect(ownsRow(userAuth("erin", ["green"]), grouped, blueRow)).toBe(false);
|
|
130
|
-
expect(
|
|
131
|
-
canActOnRow(userAuth("erin", ["green"]), grouped, "update", blueRow),
|
|
132
|
-
).toBe(false);
|
|
133
|
-
// adminGroups members act across groups.
|
|
134
|
-
expect(ownsRow(userAuth("sam", ["staff"]), grouped, blueRow)).toBe(true);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("owner + group guards both apply when a policy carries both", () => {
|
|
138
|
-
const both: ResourcePolicy = {
|
|
139
|
-
access: { ownerField: "owner", groupField: "team" },
|
|
140
|
-
};
|
|
141
|
-
const row = { id: 1, owner: "dana", team: "blue" };
|
|
142
|
-
expect(ownsRow(userAuth("dana", ["blue"]), both, row)).toBe(true);
|
|
143
|
-
expect(ownsRow(userAuth("dana", ["green"]), both, row)).toBe(false);
|
|
144
|
-
expect(ownsRow(userAuth("erin", ["blue"]), both, row)).toBe(false);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("isAdmin is the scope-driven admin role", () => {
|
|
148
|
-
expect(isAdmin(userAuth("sam", ["staff"]), grouped)).toBe(true);
|
|
149
|
-
expect(isAdmin(userAuth("dana", ["blue"]), grouped)).toBe(false);
|
|
150
|
-
expect(isAdmin(trustedAuth(), grouped)).toBe(true);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe("rastack/auth — building an identity", () => {
|
|
155
|
-
it("authFromClaims reads sub and cognito:groups from a decoded token", () => {
|
|
156
|
-
const auth = authFromClaims({
|
|
157
|
-
sub: "u1",
|
|
158
|
-
"cognito:groups": ["staff", 42],
|
|
159
|
-
email: "x@y.z",
|
|
160
|
-
});
|
|
161
|
-
expect(auth).toEqual({ authenticated: true, sub: "u1", groups: ["staff"] });
|
|
162
|
-
expect(authFromClaims({}).authenticated).toBe(false);
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("rastack/auth — policy lookup", () => {
|
|
167
|
-
const manifest = {
|
|
168
|
-
resources: [
|
|
169
|
-
{ app: "airports", model: "airport", fields: [] },
|
|
170
|
-
{
|
|
171
|
-
app: "airports",
|
|
172
|
-
model: "itinerary",
|
|
173
|
-
fields: [],
|
|
174
|
-
permission: "authenticated",
|
|
175
|
-
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
176
|
-
},
|
|
177
|
-
],
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
it("policyFromManifest extracts permission + access", () => {
|
|
181
|
-
expect(policyFromManifest(manifest, "airports.itinerary")).toEqual(rls);
|
|
182
|
-
expect(
|
|
183
|
-
policyFromManifest(manifest, { app: "airports", model: "airport" }),
|
|
184
|
-
).toEqual({});
|
|
185
|
-
expect(policyFromManifest(manifest, "airports.missing")).toBeUndefined();
|
|
186
|
-
// Accepts the JSON string form the provider may hold.
|
|
187
|
-
expect(
|
|
188
|
-
policyFromManifest(JSON.stringify(manifest), "airports.itinerary"),
|
|
189
|
-
).toEqual(rls);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("policyFromOpenApi reads the x-rastack-permission / x-rastack-access stamps", () => {
|
|
193
|
-
const doc = {
|
|
194
|
-
paths: {
|
|
195
|
-
"/api/airports/v1/itinerary/": {
|
|
196
|
-
get: {
|
|
197
|
-
operationId: "listItinerary",
|
|
198
|
-
"x-rastack-permission": "authenticated",
|
|
199
|
-
"x-rastack-access": rls.access,
|
|
200
|
-
},
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
};
|
|
204
|
-
expect(policyFromOpenApi(doc, "airports.itinerary")).toEqual(rls);
|
|
205
|
-
expect(policyFromOpenApi(doc, "airports.airport")).toBeUndefined();
|
|
206
|
-
});
|
|
207
|
-
});
|