@homeflare/alchemy 0.5.0 → 0.7.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.
- package/README.md +68 -55
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +24 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1518 -310
- package/dist/openbao/index.js.map +40 -17
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +2 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +163 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `auth/<mount>/config` for a JWT-validating mount — the issuer, keys and algorithms a login token
|
|
3
|
+
* is checked against. OPTIONAL, NON-SECRET, and deliberately unable to configure an OIDC browser
|
|
4
|
+
* login: read the ⛔ in jwt-config-form.ts for why `oidc_client_secret` stays out of Alchemy.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ THERE IS NO DELETE. openbao v2.6.2 path_config.go:126-141 defines read and update only; the
|
|
7
|
+
* config lives exactly as long as its mount. So `delete` here writes nothing and says so in its
|
|
8
|
+
* comment — it cannot be made to "remove" a config, and refusing would wedge every destroy. The
|
|
9
|
+
* `retain` default means it only runs when a stack opted into destroy.
|
|
10
|
+
*
|
|
11
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` changed → `replace`: the new mount gets the config;
|
|
12
|
+
* the old one keeps its own until that mount is disabled. There is no identity but the mount.
|
|
13
|
+
*/
|
|
14
|
+
import { Resource } from 'alchemy';
|
|
15
|
+
import { isResolved } from 'alchemy/Diff';
|
|
16
|
+
import * as Provider from 'alchemy/Provider';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
+
import {
|
|
20
|
+
type BaoJwtAuthConfigAttributes,
|
|
21
|
+
type BaoJwtAuthConfigProps,
|
|
22
|
+
attributesOf,
|
|
23
|
+
configPath,
|
|
24
|
+
matches,
|
|
25
|
+
mountOf,
|
|
26
|
+
problems,
|
|
27
|
+
wouldErase,
|
|
28
|
+
writeBody,
|
|
29
|
+
} from './jwt-config-form.ts';
|
|
30
|
+
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
31
|
+
|
|
32
|
+
export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
|
|
33
|
+
|
|
34
|
+
export interface BaoJwtAuthConfig extends Resource<
|
|
35
|
+
'Bao.JwtAuthConfig',
|
|
36
|
+
BaoJwtAuthConfigProps,
|
|
37
|
+
BaoJwtAuthConfigAttributes,
|
|
38
|
+
never,
|
|
39
|
+
HttpClient.HttpClient
|
|
40
|
+
> {}
|
|
41
|
+
|
|
42
|
+
export const BaoJwtAuthConfig = Resource<BaoJwtAuthConfig>('Bao.JwtAuthConfig', {
|
|
43
|
+
defaultRemovalPolicy: 'retain',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const jwtConfigSpec = (
|
|
47
|
+
props: BaoJwtAuthConfigProps,
|
|
48
|
+
): RoleSpec<BaoJwtAuthConfigAttributes> => ({
|
|
49
|
+
attributesOf: (live) => attributesOf(props, live),
|
|
50
|
+
body: writeBody(props),
|
|
51
|
+
family: 'Bao.JwtAuthConfig',
|
|
52
|
+
matches: (attributes) => matches(attributes, props),
|
|
53
|
+
path: configPath(props),
|
|
54
|
+
problems: problems(props),
|
|
55
|
+
wouldErase,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const BaoJwtAuthConfigProvider = () =>
|
|
59
|
+
Provider.effect(
|
|
60
|
+
BaoJwtAuthConfig,
|
|
61
|
+
Effect.succeed(
|
|
62
|
+
BaoJwtAuthConfig.Provider.of({
|
|
63
|
+
list: () => Effect.succeed([]),
|
|
64
|
+
|
|
65
|
+
read: Effect.fn(function* ({ olds }) {
|
|
66
|
+
const found = yield* readRoleAt(jwtConfigSpec(olds));
|
|
67
|
+
return found?.attributes;
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
71
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
72
|
+
if (mountOf(news) !== output.mount) return { action: 'replace' } as const;
|
|
73
|
+
return { action: yield* planRole(jwtConfigSpec(news)) } as const;
|
|
74
|
+
}),
|
|
75
|
+
|
|
76
|
+
/** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
|
|
77
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
78
|
+
return yield* reconcileRole(jwtConfigSpec(news));
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
/** ⚠️ Writes nothing — there is no delete endpoint (header). */
|
|
82
|
+
delete: () => Effect.void,
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.JwtRole's form. Worth pinning: the body carries the four fields the server RESETS when they
|
|
3
|
+
* are omitted, maps go over as objects, a role read back from OpenBao compares equal to its
|
|
4
|
+
* declaration, and the server's own refusals are refused here first. Values are placeholders.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
type BaoJwtRoleProps,
|
|
10
|
+
attributesOf,
|
|
11
|
+
matches,
|
|
12
|
+
problems,
|
|
13
|
+
writeBody,
|
|
14
|
+
} from './jwt-role-form.ts';
|
|
15
|
+
|
|
16
|
+
const CI: BaoJwtRoleProps = {
|
|
17
|
+
boundAudiences: ['https://github.com/example-org'],
|
|
18
|
+
boundClaims: { ref: 'refs/heads/main', repository: ['example-org/app', 'example-org/api'] },
|
|
19
|
+
boundClaimsType: 'glob',
|
|
20
|
+
mount: 'jwt-github',
|
|
21
|
+
name: 'ci-app',
|
|
22
|
+
roleType: 'jwt',
|
|
23
|
+
tokenMaxTtl: '30m',
|
|
24
|
+
tokenPolicies: ['ci-app-deploy'],
|
|
25
|
+
tokenTtl: '10m',
|
|
26
|
+
userClaim: 'repository',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** What OpenBao 2.6.2 answers for CI after the write (path_role.go pathRoleRead + tokenutil). */
|
|
30
|
+
const LIVE: Record<string, unknown> = {
|
|
31
|
+
allowed_redirect_uris: null,
|
|
32
|
+
bound_audiences: ['https://github.com/example-org'],
|
|
33
|
+
bound_claims: { ref: 'refs/heads/main', repository: ['example-org/api', 'example-org/app'] },
|
|
34
|
+
bound_claims_type: 'glob',
|
|
35
|
+
bound_subject: '',
|
|
36
|
+
callback_mode: 'client',
|
|
37
|
+
claim_mappings: null,
|
|
38
|
+
clock_skew_leeway: 60,
|
|
39
|
+
groups_claim: '',
|
|
40
|
+
oidc_disable_confirmation: false,
|
|
41
|
+
oidc_scopes: null,
|
|
42
|
+
role_type: 'jwt',
|
|
43
|
+
token_bound_cidrs: [],
|
|
44
|
+
token_explicit_max_ttl: 0,
|
|
45
|
+
token_max_ttl: 1800,
|
|
46
|
+
token_no_default_policy: false,
|
|
47
|
+
token_num_uses: 0,
|
|
48
|
+
token_period: 0,
|
|
49
|
+
token_policies: ['ci-app-deploy'],
|
|
50
|
+
token_ttl: 600,
|
|
51
|
+
token_type: 'default',
|
|
52
|
+
user_claim: 'repository',
|
|
53
|
+
verbose_oidc_logging: false,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const same = (live: Record<string, unknown>, props = CI) =>
|
|
57
|
+
matches(attributesOf(props, live), props);
|
|
58
|
+
|
|
59
|
+
describe('jwt role form', () => {
|
|
60
|
+
it('reads the role OpenBao stored as the role that was declared', () => {
|
|
61
|
+
assert.equal(same(LIVE), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('sends the four fields the server resets when omitted, and maps as objects', () => {
|
|
65
|
+
const body = writeBody(CI);
|
|
66
|
+
assert.equal(body['role_type'], 'jwt');
|
|
67
|
+
assert.equal(body['bound_claims_type'], 'glob');
|
|
68
|
+
assert.equal(body['callback_mode'], 'client');
|
|
69
|
+
assert.equal(body['oidc_disable_confirmation'], false);
|
|
70
|
+
assert.equal(body['verbose_oidc_logging'], false);
|
|
71
|
+
assert.deepEqual(body['bound_claims'], CI.boundClaims);
|
|
72
|
+
assert.deepEqual(body['claim_mappings'], {});
|
|
73
|
+
assert.deepEqual(body['token_policies'], ['ci-app-deploy']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('sends every token_* field at its default — the write merges, so an omitted one stays stale', () => {
|
|
77
|
+
const bare: BaoJwtRoleProps = {
|
|
78
|
+
boundSubject: 'repo:example-org/app:ref:refs/heads/main',
|
|
79
|
+
name: 'bare',
|
|
80
|
+
roleType: 'jwt',
|
|
81
|
+
tokenPolicies: [],
|
|
82
|
+
userClaim: 'sub',
|
|
83
|
+
};
|
|
84
|
+
const token = Object.fromEntries(
|
|
85
|
+
Object.entries(writeBody(bare)).filter(([key]) => key.startsWith('token_')),
|
|
86
|
+
);
|
|
87
|
+
assert.deepEqual(token, {
|
|
88
|
+
token_bound_cidrs: [],
|
|
89
|
+
token_explicit_max_ttl: '0',
|
|
90
|
+
token_max_ttl: '0',
|
|
91
|
+
token_no_default_policy: false,
|
|
92
|
+
token_num_uses: 0,
|
|
93
|
+
token_period: '0',
|
|
94
|
+
token_policies: [],
|
|
95
|
+
token_ttl: '0',
|
|
96
|
+
token_type: 'default',
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('plans update for drift on a managed field, including the two fixed-false ones', () => {
|
|
101
|
+
const drifted: [string, unknown][] = [
|
|
102
|
+
['verbose_oidc_logging', true],
|
|
103
|
+
['oidc_disable_confirmation', true],
|
|
104
|
+
['role_type', 'oidc'],
|
|
105
|
+
['token_policies', ['ci-app-deploy', 'admin']],
|
|
106
|
+
['bound_audiences', ['https://github.com/other-org']],
|
|
107
|
+
['token_ttl', 3600],
|
|
108
|
+
];
|
|
109
|
+
for (const [field, value] of drifted)
|
|
110
|
+
assert.equal(same({ ...LIVE, [field]: value }), false, field);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('treats a claim value and a one-element list as the same', () => {
|
|
114
|
+
const props = {
|
|
115
|
+
...CI,
|
|
116
|
+
boundClaims: { ref: ['refs/heads/main'], repository: CI.boundClaims?.['repository'] ?? [] },
|
|
117
|
+
};
|
|
118
|
+
assert.equal(same(LIVE, props), true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses what the server would refuse, before any call', () => {
|
|
122
|
+
assert.deepEqual(problems(CI), []);
|
|
123
|
+
const loose = { ...CI, boundAudiences: [], boundClaims: {} };
|
|
124
|
+
assert.match(problems(loose).join(), /bound constraint/);
|
|
125
|
+
assert.match(problems({ ...CI, roleType: 'oidc' }).join(), /allowedRedirectUris/);
|
|
126
|
+
assert.match(problems({ ...CI, name: 'CI-App' }).join(), /lower case/);
|
|
127
|
+
assert.match(problems({ ...CI, claimMappings: { email: 'role' } }).join(), /reserved/);
|
|
128
|
+
assert.match(problems({ ...CI, tokenTtl: '2h' }).join(), /longer than tokenMaxTtl/);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form half of Bao.JwtRole — a role on a `jwt` or `oidc` auth mount (one plugin, two names).
|
|
3
|
+
*
|
|
4
|
+
* ★ READ FROM openbao v2.6.2 builtin/credential/jwt/path_role.go, NOT RECALLED:
|
|
5
|
+
* · The write MERGES (pathRoleCreateUpdate :494-720): almost every field is `GetOk`, so an
|
|
6
|
+
* omitted one keeps its stored value — EXCEPT four that are rebuilt from `data.Get` on every
|
|
7
|
+
* write and so RESET when omitted: `role_type` (to `oidc`! :520-526), `bound_claims_type`
|
|
8
|
+
* (`string`), `callback_mode` (`client`) and `oidc_disable_confirmation` (false). All four are
|
|
9
|
+
* managed here, and every managed field is sent on every write, defaults included, so a value
|
|
10
|
+
* set by hand cannot survive behind a green plan.
|
|
11
|
+
* · `name` is a lower-case string (:77): `Admins` and `admins` are one role. Refused, not folded.
|
|
12
|
+
* · A `jwt` role needs one bound constraint (audiences, subject, claims or token CIDRs) and an
|
|
13
|
+
* `oidc` role needs allowed_redirect_uris (:694-709) — refused here with the same rule.
|
|
14
|
+
* · `bound_claims` is a TypeMap and `claim_mappings` a TypeKVPairs: sent as JSON OBJECTS. A JSON
|
|
15
|
+
* string is refused for a map (the ⛔ on mapValue in ssh-role-form.ts, measured 2026-09-21).
|
|
16
|
+
* ⛔ `verbose_oidc_logging` AND `oidc_disable_confirmation` ARE ALWAYS SENT FALSE, and a live true
|
|
17
|
+
* plans `update`. The first logs received tokens and claims at debug level (:189-194); the second
|
|
18
|
+
* removes the confirmation page that guards direct-callback logins against token hijacking
|
|
19
|
+
* (RFC 8628 5.4, :205-210). Neither is a prop.
|
|
20
|
+
* ⚠️ LEFT ALONE (merged, never sent): the three leeways, `oauth2_metadata`, `max_age`,
|
|
21
|
+
* `poll_interval`, `user_claim_json_pointer`, `token_policies_template_claims`,
|
|
22
|
+
* `token_strictly_bind_ip`. A hand-set value there survives, and is not compared.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
type BaoTokenForm,
|
|
26
|
+
type BaoTokenProps,
|
|
27
|
+
tokenBody,
|
|
28
|
+
tokenFields,
|
|
29
|
+
tokenFormOfLive,
|
|
30
|
+
tokenFormOfProps,
|
|
31
|
+
tokenProblems,
|
|
32
|
+
} from './auth-token-form.ts';
|
|
33
|
+
import { sha256 } from './digest.ts';
|
|
34
|
+
import { mountPath } from './mount-form.ts';
|
|
35
|
+
|
|
36
|
+
export type BaoJwtRoleType = 'jwt' | 'oidc';
|
|
37
|
+
export type BaoJwtCallbackMode = 'client' | 'device' | 'direct';
|
|
38
|
+
|
|
39
|
+
export interface BaoJwtRoleProps extends BaoTokenProps {
|
|
40
|
+
/** Auth mount path, no trailing slash. Default `jwt`; an OIDC mount is often `oidc`. */
|
|
41
|
+
mount?: string;
|
|
42
|
+
/** Role name — lower case, as the server stores it. */
|
|
43
|
+
name: string;
|
|
44
|
+
/** ⛔ REQUIRED ON PURPOSE: the server's default is `oidc`, even when re-writing a `jwt` role. */
|
|
45
|
+
roleType: BaoJwtRoleType;
|
|
46
|
+
/** The claim that names the entity alias, e.g. `sub` or `email`. */
|
|
47
|
+
userClaim: string;
|
|
48
|
+
/** `aud` values; any one must match. */
|
|
49
|
+
boundAudiences?: readonly string[];
|
|
50
|
+
/** The `sub` a token must carry. */
|
|
51
|
+
boundSubject?: string;
|
|
52
|
+
/** How `boundClaims` values match. Default `string`. */
|
|
53
|
+
boundClaimsType?: 'glob' | 'string';
|
|
54
|
+
/** Claims that must match, each a value or a list of acceptable values. */
|
|
55
|
+
boundClaims?: Readonly<Record<string, string | readonly string[]>>;
|
|
56
|
+
/** Claim → alias metadata key. `role` is reserved, and two claims may not share a key. */
|
|
57
|
+
claimMappings?: Readonly<Record<string, string>>;
|
|
58
|
+
/** The claim that names identity group aliases. */
|
|
59
|
+
groupsClaim?: string;
|
|
60
|
+
/** OIDC only: the redirect URIs a login may return to. Required for `oidc`. */
|
|
61
|
+
allowedRedirectUris?: readonly string[];
|
|
62
|
+
/** OIDC only: scopes to request beyond `openid`. */
|
|
63
|
+
oidcScopes?: readonly string[];
|
|
64
|
+
/** OIDC only. Default `client`. */
|
|
65
|
+
callbackMode?: BaoJwtCallbackMode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every managed field, normalised. Both sides are built by `canonical`, so key order matches. */
|
|
69
|
+
export interface BaoJwtRoleCanonical extends BaoTokenForm {
|
|
70
|
+
allowedRedirectUris: readonly string[];
|
|
71
|
+
boundAudiences: readonly string[];
|
|
72
|
+
boundClaims: Readonly<Record<string, readonly string[]>>;
|
|
73
|
+
boundClaimsType: string;
|
|
74
|
+
boundSubject: string;
|
|
75
|
+
callbackMode: string;
|
|
76
|
+
claimMappings: Readonly<Record<string, string>>;
|
|
77
|
+
groupsClaim: string;
|
|
78
|
+
oidcDisableConfirmation: boolean;
|
|
79
|
+
oidcScopes: readonly string[];
|
|
80
|
+
roleType: string;
|
|
81
|
+
userClaim: string;
|
|
82
|
+
verboseOidcLogging: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** ⛔ No secret: claim names, audiences, URIs, policy names and TTLs. */
|
|
86
|
+
export interface BaoJwtRoleAttributes extends BaoJwtRoleCanonical {
|
|
87
|
+
mount: string;
|
|
88
|
+
name: string;
|
|
89
|
+
digest: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sortedSet = (values: readonly string[]) => [...new Set(values)].sort();
|
|
93
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
94
|
+
const strings = (value: unknown): readonly string[] =>
|
|
95
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
96
|
+
const record = (value: unknown): Record<string, unknown> =>
|
|
97
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
98
|
+
? (value as Record<string, unknown>)
|
|
99
|
+
: {};
|
|
100
|
+
|
|
101
|
+
/** ⚠️ A claim value and a one-element list match alike (claims.go normalizeList), so both → list. */
|
|
102
|
+
const claimsOf = (value: unknown): Record<string, readonly string[]> => {
|
|
103
|
+
const source = record(value);
|
|
104
|
+
const out: Record<string, readonly string[]> = {};
|
|
105
|
+
for (const key of Object.keys(source).sort()) {
|
|
106
|
+
const raw = source[key];
|
|
107
|
+
out[key] = sortedSet(typeof raw === 'string' ? [raw] : strings(raw));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const mappingsOf = (value: unknown): Record<string, string> => {
|
|
113
|
+
const source = record(value);
|
|
114
|
+
const out: Record<string, string> = {};
|
|
115
|
+
for (const key of Object.keys(source).sort()) out[key] = text(source[key]);
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const canonical = (input: BaoJwtRoleCanonical): BaoJwtRoleCanonical => ({
|
|
120
|
+
allowedRedirectUris: sortedSet(input.allowedRedirectUris),
|
|
121
|
+
boundAudiences: sortedSet(input.boundAudiences),
|
|
122
|
+
boundClaims: claimsOf(input.boundClaims),
|
|
123
|
+
boundClaimsType: input.boundClaimsType,
|
|
124
|
+
boundSubject: input.boundSubject,
|
|
125
|
+
callbackMode: input.callbackMode,
|
|
126
|
+
claimMappings: mappingsOf(input.claimMappings),
|
|
127
|
+
groupsClaim: input.groupsClaim,
|
|
128
|
+
oidcDisableConfirmation: input.oidcDisableConfirmation,
|
|
129
|
+
oidcScopes: sortedSet(input.oidcScopes),
|
|
130
|
+
roleType: input.roleType,
|
|
131
|
+
userClaim: input.userClaim,
|
|
132
|
+
verboseOidcLogging: input.verboseOidcLogging,
|
|
133
|
+
...tokenFields(input),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const canonicalFromProps = (props: BaoJwtRoleProps): BaoJwtRoleCanonical =>
|
|
137
|
+
canonical({
|
|
138
|
+
...tokenFormOfProps(props),
|
|
139
|
+
allowedRedirectUris: props.allowedRedirectUris ?? [],
|
|
140
|
+
boundAudiences: props.boundAudiences ?? [],
|
|
141
|
+
boundClaims: claimsOf(props.boundClaims ?? {}),
|
|
142
|
+
boundClaimsType: props.boundClaimsType ?? 'string',
|
|
143
|
+
boundSubject: props.boundSubject ?? '',
|
|
144
|
+
callbackMode: props.callbackMode ?? 'client',
|
|
145
|
+
claimMappings: props.claimMappings ?? {},
|
|
146
|
+
groupsClaim: props.groupsClaim ?? '',
|
|
147
|
+
oidcDisableConfirmation: false,
|
|
148
|
+
oidcScopes: props.oidcScopes ?? [],
|
|
149
|
+
roleType: props.roleType,
|
|
150
|
+
userClaim: props.userClaim,
|
|
151
|
+
verboseOidcLogging: false,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
export const canonicalFromLive = (live: Record<string, unknown>): BaoJwtRoleCanonical =>
|
|
155
|
+
canonical({
|
|
156
|
+
...tokenFormOfLive(live),
|
|
157
|
+
allowedRedirectUris: strings(live['allowed_redirect_uris']),
|
|
158
|
+
boundAudiences: strings(live['bound_audiences']),
|
|
159
|
+
boundClaims: claimsOf(live['bound_claims']),
|
|
160
|
+
boundClaimsType: text(live['bound_claims_type']),
|
|
161
|
+
boundSubject: text(live['bound_subject']),
|
|
162
|
+
callbackMode: text(live['callback_mode']),
|
|
163
|
+
claimMappings: mappingsOf(live['claim_mappings']),
|
|
164
|
+
groupsClaim: text(live['groups_claim']),
|
|
165
|
+
oidcDisableConfirmation: live['oidc_disable_confirmation'] === true,
|
|
166
|
+
oidcScopes: strings(live['oidc_scopes']),
|
|
167
|
+
roleType: text(live['role_type']),
|
|
168
|
+
userClaim: text(live['user_claim']),
|
|
169
|
+
verboseOidcLogging: live['verbose_oidc_logging'] === true,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export const mountOf = (props: { mount?: string }): string => mountPath(props.mount ?? 'jwt');
|
|
173
|
+
|
|
174
|
+
export const rolePath = (props: { mount?: string; name: string }): string =>
|
|
175
|
+
`auth/${mountOf(props)}/role/${props.name}`;
|
|
176
|
+
|
|
177
|
+
export const attributesOf = (
|
|
178
|
+
props: BaoJwtRoleProps,
|
|
179
|
+
live: Record<string, unknown>,
|
|
180
|
+
): BaoJwtRoleAttributes => {
|
|
181
|
+
const form = canonicalFromLive(live);
|
|
182
|
+
return { ...form, digest: sha256(JSON.stringify(form)), mount: mountOf(props), name: props.name };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const matches = (attributes: BaoJwtRoleAttributes, props: BaoJwtRoleProps): boolean =>
|
|
186
|
+
attributes.digest === sha256(JSON.stringify(canonicalFromProps(props)));
|
|
187
|
+
|
|
188
|
+
/** The PUT body — every managed field, the two fixed-false ones included (the ⛔ above). */
|
|
189
|
+
export const writeBody = (props: BaoJwtRoleProps): Record<string, unknown> => ({
|
|
190
|
+
allowed_redirect_uris: [...(props.allowedRedirectUris ?? [])],
|
|
191
|
+
bound_audiences: [...(props.boundAudiences ?? [])],
|
|
192
|
+
bound_claims: { ...props.boundClaims },
|
|
193
|
+
bound_claims_type: props.boundClaimsType ?? 'string',
|
|
194
|
+
bound_subject: props.boundSubject ?? '',
|
|
195
|
+
callback_mode: props.callbackMode ?? 'client',
|
|
196
|
+
claim_mappings: { ...props.claimMappings },
|
|
197
|
+
groups_claim: props.groupsClaim ?? '',
|
|
198
|
+
oidc_disable_confirmation: false,
|
|
199
|
+
oidc_scopes: [...(props.oidcScopes ?? [])],
|
|
200
|
+
role_type: props.roleType,
|
|
201
|
+
user_claim: props.userClaim,
|
|
202
|
+
verbose_oidc_logging: false,
|
|
203
|
+
...tokenBody(props),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const NAME = /^[a-z0-9_](?:[a-z0-9_.-]*[a-z0-9_])?$/;
|
|
207
|
+
|
|
208
|
+
export const problems = (props: BaoJwtRoleProps): readonly string[] => {
|
|
209
|
+
const found = [...tokenProblems(props)];
|
|
210
|
+
if (!NAME.test(props.name))
|
|
211
|
+
found.push(`name \`${props.name}\` must be lower case (the server folds it)`);
|
|
212
|
+
if (props.userClaim.trim() === '') found.push('userClaim is empty; the server requires one');
|
|
213
|
+
const bound =
|
|
214
|
+
(props.boundAudiences?.length ?? 0) > 0 ||
|
|
215
|
+
(props.boundSubject ?? '') !== '' ||
|
|
216
|
+
Object.keys(props.boundClaims ?? {}).length > 0 ||
|
|
217
|
+
(props.tokenBoundCidrs?.length ?? 0) > 0;
|
|
218
|
+
if (props.roleType === 'jwt' && !bound) {
|
|
219
|
+
found.push(
|
|
220
|
+
'a jwt role needs a bound constraint: boundAudiences, boundSubject, boundClaims or tokenBoundCidrs',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (props.roleType === 'oidc' && (props.allowedRedirectUris?.length ?? 0) === 0) {
|
|
224
|
+
found.push('an oidc role needs allowedRedirectUris');
|
|
225
|
+
}
|
|
226
|
+
const targets = Object.values(props.claimMappings ?? {});
|
|
227
|
+
if (targets.includes('role')) found.push('claimMappings may not map to the reserved key `role`');
|
|
228
|
+
if (new Set(targets).size !== targets.length)
|
|
229
|
+
found.push('claimMappings maps two claims to one key');
|
|
230
|
+
return found;
|
|
231
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A role on a `jwt` or `oidc` auth mount — `auth/<mount>/role/<name>`: which tokens may log in,
|
|
3
|
+
* how the entity is named, and what the resulting OpenBao token carries. METADATA ONLY.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ NO SECRET IN PROPS OR ATTRIBUTES — see policy.ts. A role holds claim names, audiences, redirect
|
|
6
|
+
* URIs and policy names. The mount's OIDC client secret is config, not role, and stays out of
|
|
7
|
+
* Alchemy entirely (the ⛔ in jwt-config.ts).
|
|
8
|
+
* ⛔ HUMANS GET A NON-ADMIN DEFAULT (machine-access plan, "Admin boundary"): a human role's
|
|
9
|
+
* `tokenPolicies` should not include an admin policy, and admin needs login MFA
|
|
10
|
+
* (mfa-enforcement.ts). This resource does not know which policy is admin, so it cannot refuse —
|
|
11
|
+
* the stack that declares the role owns that line.
|
|
12
|
+
* ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED. path_role.go:478 pathRoleDelete removes the
|
|
13
|
+
* storage entry and nothing else; those tokens live to their TTL.
|
|
14
|
+
*
|
|
15
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first. The new
|
|
16
|
+
* path cannot collide with the old, so no `deleteFirst`. Under the default `retain` the old role
|
|
17
|
+
* stays live, and still admits logins, until removed by hand.
|
|
18
|
+
* ★ `defaultRemovalPolicy: 'retain'`, like every Bao.* family.
|
|
19
|
+
*/
|
|
20
|
+
import { Resource } from 'alchemy';
|
|
21
|
+
import { isResolved } from 'alchemy/Diff';
|
|
22
|
+
import * as Provider from 'alchemy/Provider';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
25
|
+
import { baoDelete } from './bao-http.ts';
|
|
26
|
+
import {
|
|
27
|
+
type BaoJwtRoleAttributes,
|
|
28
|
+
type BaoJwtRoleProps,
|
|
29
|
+
attributesOf,
|
|
30
|
+
matches,
|
|
31
|
+
mountOf,
|
|
32
|
+
problems,
|
|
33
|
+
rolePath,
|
|
34
|
+
writeBody,
|
|
35
|
+
} from './jwt-role-form.ts';
|
|
36
|
+
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
37
|
+
|
|
38
|
+
export type {
|
|
39
|
+
BaoJwtCallbackMode,
|
|
40
|
+
BaoJwtRoleAttributes,
|
|
41
|
+
BaoJwtRoleProps,
|
|
42
|
+
BaoJwtRoleType,
|
|
43
|
+
} from './jwt-role-form.ts';
|
|
44
|
+
|
|
45
|
+
export interface BaoJwtRole extends Resource<
|
|
46
|
+
'Bao.JwtRole',
|
|
47
|
+
BaoJwtRoleProps,
|
|
48
|
+
BaoJwtRoleAttributes,
|
|
49
|
+
never,
|
|
50
|
+
HttpClient.HttpClient
|
|
51
|
+
> {}
|
|
52
|
+
|
|
53
|
+
export const BaoJwtRole = Resource<BaoJwtRole>('Bao.JwtRole', {
|
|
54
|
+
defaultRemovalPolicy: 'retain',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const jwtRoleSpec = (props: BaoJwtRoleProps): RoleSpec<BaoJwtRoleAttributes> => ({
|
|
58
|
+
attributesOf: (live) => attributesOf(props, live),
|
|
59
|
+
body: writeBody(props),
|
|
60
|
+
family: 'Bao.JwtRole',
|
|
61
|
+
matches: (attributes) => matches(attributes, props),
|
|
62
|
+
path: rolePath(props),
|
|
63
|
+
problems: problems(props),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const BaoJwtRoleProvider = () =>
|
|
67
|
+
Provider.effect(
|
|
68
|
+
BaoJwtRole,
|
|
69
|
+
Effect.succeed(
|
|
70
|
+
BaoJwtRole.Provider.of({
|
|
71
|
+
/**
|
|
72
|
+
* ⛔ `bao list auth/<mount>/role` IS NOT A LIST OF THINGS THIS OWNS — every role answers,
|
|
73
|
+
* including the hand-made admin lanes. Returning them invites adopt-then-delete.
|
|
74
|
+
*/
|
|
75
|
+
list: () => Effect.succeed([]),
|
|
76
|
+
|
|
77
|
+
read: Effect.fn(function* ({ olds }) {
|
|
78
|
+
const found = yield* readRoleAt(jwtRoleSpec(olds));
|
|
79
|
+
return found?.attributes;
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
|
|
83
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
84
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
85
|
+
if (mountOf(news) !== output.mount || news.name !== output.name) {
|
|
86
|
+
return { action: 'replace' } as const;
|
|
87
|
+
}
|
|
88
|
+
return { action: yield* planRole(jwtRoleSpec(news)) } as const;
|
|
89
|
+
}),
|
|
90
|
+
|
|
91
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
92
|
+
return yield* reconcileRole(jwtRoleSpec(news));
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
/** Idempotent as Alchemy requires — a missing role deletes as success. */
|
|
96
|
+
delete: Effect.fn(function* ({ output }) {
|
|
97
|
+
yield* baoDelete(rolePath(output));
|
|
98
|
+
return undefined;
|
|
99
|
+
}),
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.KubernetesRole's form: the selector the server resets is always sent, a read-back compares
|
|
3
|
+
* equal, and the server's refusals are refused first. Placeholder names throughout.
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
type BaoKubernetesRoleProps,
|
|
9
|
+
attributesOf,
|
|
10
|
+
matches,
|
|
11
|
+
problems,
|
|
12
|
+
writeBody,
|
|
13
|
+
} from './kubernetes-role-form.ts';
|
|
14
|
+
|
|
15
|
+
const POD: BaoKubernetesRoleProps = {
|
|
16
|
+
aliasNameSource: 'serviceaccount_name',
|
|
17
|
+
boundServiceAccountNames: ['litellm'],
|
|
18
|
+
boundServiceAccountNamespaces: ['ai'],
|
|
19
|
+
name: 'ai-litellm',
|
|
20
|
+
tokenPolicies: ['ai-litellm'],
|
|
21
|
+
tokenTtl: '1h',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LIVE: Record<string, unknown> = {
|
|
25
|
+
alias_name_source: 'serviceaccount_name',
|
|
26
|
+
bound_service_account_names: ['litellm'],
|
|
27
|
+
bound_service_account_namespace_selector: '',
|
|
28
|
+
bound_service_account_namespaces: ['ai'],
|
|
29
|
+
token_bound_cidrs: [],
|
|
30
|
+
token_explicit_max_ttl: 0,
|
|
31
|
+
token_max_ttl: 0,
|
|
32
|
+
token_no_default_policy: false,
|
|
33
|
+
token_num_uses: 0,
|
|
34
|
+
token_period: 0,
|
|
35
|
+
token_policies: ['ai-litellm'],
|
|
36
|
+
token_ttl: 3600,
|
|
37
|
+
token_type: 'default',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const same = (live: Record<string, unknown>, props = POD) =>
|
|
41
|
+
matches(attributesOf(props, live), props);
|
|
42
|
+
|
|
43
|
+
describe('kubernetes role form', () => {
|
|
44
|
+
it('reads the stored role as the declared one; audience absent reads as none', () => {
|
|
45
|
+
assert.equal(same(LIVE), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('plans update when the alias source, names or audience drift', () => {
|
|
49
|
+
assert.equal(same({ ...LIVE, alias_name_source: 'serviceaccount_uid' }), false);
|
|
50
|
+
assert.equal(same({ ...LIVE, bound_service_account_names: ['*'] }), false);
|
|
51
|
+
assert.equal(same({ ...LIVE, audience: 'vault' }), false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('always sends the selector and audience, so a stale one is cleared', () => {
|
|
55
|
+
const body = writeBody(POD);
|
|
56
|
+
assert.equal(body['bound_service_account_namespace_selector'], '');
|
|
57
|
+
assert.equal(body['audience'], '');
|
|
58
|
+
assert.equal(body['alias_name_source'], 'serviceaccount_name');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('sends the declared alias source, whichever it is — there is no default to fall back on', () => {
|
|
62
|
+
const uid = { ...POD, aliasNameSource: 'serviceaccount_uid' as const };
|
|
63
|
+
assert.equal(writeBody(uid)['alias_name_source'], 'serviceaccount_uid');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('refuses empty names, a mixed `*`, no namespaces, and upper case', () => {
|
|
67
|
+
assert.deepEqual(problems(POD), []);
|
|
68
|
+
assert.match(problems({ ...POD, boundServiceAccountNames: [] }).join(), /is empty/);
|
|
69
|
+
assert.match(problems({ ...POD, boundServiceAccountNames: ['*', 'x'] }).join(), /mixed/);
|
|
70
|
+
assert.match(
|
|
71
|
+
problems({ ...POD, boundServiceAccountNamespaces: [] }).join(),
|
|
72
|
+
/no namespace selector/,
|
|
73
|
+
);
|
|
74
|
+
const selected = {
|
|
75
|
+
...POD,
|
|
76
|
+
boundServiceAccountNamespaceSelector: '{"matchLabels":{"a":"b"}}',
|
|
77
|
+
boundServiceAccountNamespaces: [],
|
|
78
|
+
};
|
|
79
|
+
assert.deepEqual(problems(selected), []);
|
|
80
|
+
assert.match(problems({ ...POD, name: 'AI' }).join(), /lower case/);
|
|
81
|
+
});
|
|
82
|
+
});
|