@homeflare/alchemy 0.4.1 → 0.6.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 +26 -0
- package/dist/index.d.ts +1 -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 +23 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/approle-login-form.d.ts +72 -0
- package/dist/openbao/approle-login-form.d.ts.map +1 -0
- package/dist/openbao/approle-login-result.d.ts +39 -0
- package/dist/openbao/approle-login-result.d.ts.map +1 -0
- package/dist/openbao/approle-login.d.ts +77 -0
- package/dist/openbao/approle-login.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-http.d.ts +2 -0
- package/dist/openbao/bao-http.d.ts.map +1 -1
- 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-roles-expand.d.ts.map +1 -1
- 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 +26 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1889 -477
- package/dist/openbao/index.js.map +46 -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 +18 -0
- package/dist/openbao/mount-path.d.ts.map +1 -0
- 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/plugin-form.d.ts +60 -0
- package/dist/openbao/plugin-form.d.ts.map +1 -0
- package/dist/openbao/plugin-reconcile.d.ts +18 -0
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
- package/dist/openbao/plugin-wire.d.ts +45 -0
- package/dist/openbao/plugin-wire.d.ts.map +1 -0
- package/dist/openbao/plugin.d.ts +31 -0
- package/dist/openbao/plugin.d.ts.map +1 -0
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.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/dist/openbao/ssh-role-form.d.ts +6 -5
- package/dist/openbao/ssh-role-form.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -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 +87 -0
- package/src/openbao/approle-login-form.test.ts +64 -0
- package/src/openbao/approle-login-form.ts +153 -0
- package/src/openbao/approle-login-result.ts +96 -0
- package/src/openbao/approle-login.test.ts +200 -0
- package/src/openbao/approle-login.ts +156 -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 +57 -0
- 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-http.ts +2 -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-roles-expand.ts +2 -5
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +32 -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 +33 -0
- package/src/openbao/mount-path.ts +31 -0
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount-wire.test.ts +13 -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/plugin-form.test.ts +86 -0
- package/src/openbao/plugin-form.ts +164 -0
- package/src/openbao/plugin-reconcile.ts +92 -0
- package/src/openbao/plugin-wire.test.ts +146 -0
- package/src/openbao/plugin-wire.ts +65 -0
- package/src/openbao/plugin.ts +100 -0
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/ssh-role-form.test.ts +105 -0
- package/src/openbao/ssh-role-form.ts +18 -16
- package/src/openbao/ssh-role.ts +3 -4
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form half of Bao.KubernetesRole — `auth/<mount>/role/<name>` on a `kubernetes` auth mount:
|
|
3
|
+
* which service accounts, in which namespaces, may log in, and what their token carries.
|
|
4
|
+
*
|
|
5
|
+
* ★ READ FROM openbao v2.6.2 builtin/credential/kubernetes/path_role.go, NOT RECALLED:
|
|
6
|
+
* · The write MERGES for names, namespaces, audience and alias source (`GetOk`, :335-392), but
|
|
7
|
+
* `bound_service_account_namespace_selector` is `data.Get` — RESET when omitted (:353). Every
|
|
8
|
+
* field is therefore sent on every write, defaults included.
|
|
9
|
+
* · Names may not be empty, and `*` may not be mixed with other names; namespaces may be empty
|
|
10
|
+
* only when a selector is set, with the same `*` rule (:335-372). Refused here first.
|
|
11
|
+
* · Roles are stored under the LOWER-CASED name (:396, backend.go:361), so a mixed-case name
|
|
12
|
+
* would read back as a different string. Refused, not folded.
|
|
13
|
+
* · `audience` is omitted from a read when empty (:206-208); read as `''`.
|
|
14
|
+
* ⚠️ `aliasNameSource` DECIDES WHO A POD IS. `serviceaccount_uid` (OpenBao's default) names the
|
|
15
|
+
* entity alias by the service account's UID; `serviceaccount_name` by `<namespace>/<name>`
|
|
16
|
+
* (backend.go:30-34). Changing it on a live role re-names every pod's alias, so their next logins
|
|
17
|
+
* land on NEW entities and attribution splits.
|
|
18
|
+
* ⛔ SO IT IS REQUIRED, NOT DEFAULTED (review 2026-09-21). Every field is sent on every write, so a
|
|
19
|
+
* default here would be WRITTEN: adopting a hand-made `serviceaccount_name` role with the prop
|
|
20
|
+
* left out would plan a bare `update` and silently re-key every pod onto new entities. The
|
|
21
|
+
* machine-access plan chose `serviceaccount_name`; the server's default is the other one — a
|
|
22
|
+
* choice this important is made in the declaration, the way Bao.JwtRole's `roleType` is.
|
|
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 BaoKubernetesAliasSource = 'serviceaccount_name' | 'serviceaccount_uid';
|
|
37
|
+
|
|
38
|
+
export interface BaoKubernetesRoleProps extends BaoTokenProps {
|
|
39
|
+
/** Auth mount path. Default `kubernetes`. */
|
|
40
|
+
mount?: string;
|
|
41
|
+
/** Role name — lower case, as the server stores it. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** Service account names; `['*']` for any. Never empty. */
|
|
44
|
+
boundServiceAccountNames: readonly string[];
|
|
45
|
+
/** Namespaces; `['*']` for any. May be empty only with a selector. */
|
|
46
|
+
boundServiceAccountNamespaces?: readonly string[];
|
|
47
|
+
/** A label selector (JSON or YAML) for namespaces, ORed with the list. */
|
|
48
|
+
boundServiceAccountNamespaceSelector?: string;
|
|
49
|
+
/** The `aud` a service account token must carry. Default none. */
|
|
50
|
+
audience?: string;
|
|
51
|
+
/** ⛔ Required — read the ⚠️ and ⛔ above before changing it on a live role. */
|
|
52
|
+
aliasNameSource: BaoKubernetesAliasSource;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BaoKubernetesRoleCanonical extends BaoTokenForm {
|
|
56
|
+
aliasNameSource: string;
|
|
57
|
+
audience: string;
|
|
58
|
+
boundServiceAccountNames: readonly string[];
|
|
59
|
+
boundServiceAccountNamespaceSelector: string;
|
|
60
|
+
boundServiceAccountNamespaces: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** ⛔ No secret: account names, namespaces, policy names, TTLs. */
|
|
64
|
+
export interface BaoKubernetesRoleAttributes extends BaoKubernetesRoleCanonical {
|
|
65
|
+
mount: string;
|
|
66
|
+
name: string;
|
|
67
|
+
digest: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sortedSet = (values: readonly string[]) => [...new Set(values)].sort();
|
|
71
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
72
|
+
const strings = (value: unknown): readonly string[] =>
|
|
73
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
74
|
+
|
|
75
|
+
const canonical = (input: BaoKubernetesRoleCanonical): BaoKubernetesRoleCanonical => ({
|
|
76
|
+
aliasNameSource: input.aliasNameSource,
|
|
77
|
+
audience: input.audience,
|
|
78
|
+
boundServiceAccountNames: sortedSet(input.boundServiceAccountNames),
|
|
79
|
+
boundServiceAccountNamespaceSelector: input.boundServiceAccountNamespaceSelector.trim(),
|
|
80
|
+
boundServiceAccountNamespaces: sortedSet(input.boundServiceAccountNamespaces),
|
|
81
|
+
...tokenFields(input),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const canonicalFromProps = (props: BaoKubernetesRoleProps): BaoKubernetesRoleCanonical =>
|
|
85
|
+
canonical({
|
|
86
|
+
...tokenFormOfProps(props),
|
|
87
|
+
aliasNameSource: props.aliasNameSource,
|
|
88
|
+
audience: props.audience ?? '',
|
|
89
|
+
boundServiceAccountNames: props.boundServiceAccountNames,
|
|
90
|
+
boundServiceAccountNamespaceSelector: props.boundServiceAccountNamespaceSelector ?? '',
|
|
91
|
+
boundServiceAccountNamespaces: props.boundServiceAccountNamespaces ?? [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const canonicalFromLive = (live: Record<string, unknown>): BaoKubernetesRoleCanonical =>
|
|
95
|
+
canonical({
|
|
96
|
+
...tokenFormOfLive(live),
|
|
97
|
+
aliasNameSource: text(live['alias_name_source']),
|
|
98
|
+
audience: text(live['audience']),
|
|
99
|
+
boundServiceAccountNames: strings(live['bound_service_account_names']),
|
|
100
|
+
boundServiceAccountNamespaceSelector: text(live['bound_service_account_namespace_selector']),
|
|
101
|
+
boundServiceAccountNamespaces: strings(live['bound_service_account_namespaces']),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const mountOf = (props: { mount?: string }): string =>
|
|
105
|
+
mountPath(props.mount ?? 'kubernetes');
|
|
106
|
+
|
|
107
|
+
export const rolePath = (props: { mount?: string; name: string }): string =>
|
|
108
|
+
`auth/${mountOf(props)}/role/${props.name}`;
|
|
109
|
+
|
|
110
|
+
export const attributesOf = (
|
|
111
|
+
props: BaoKubernetesRoleProps,
|
|
112
|
+
live: Record<string, unknown>,
|
|
113
|
+
): BaoKubernetesRoleAttributes => {
|
|
114
|
+
const form = canonicalFromLive(live);
|
|
115
|
+
return { ...form, digest: sha256(JSON.stringify(form)), mount: mountOf(props), name: props.name };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const matches = (
|
|
119
|
+
attributes: BaoKubernetesRoleAttributes,
|
|
120
|
+
props: BaoKubernetesRoleProps,
|
|
121
|
+
): boolean => attributes.digest === sha256(JSON.stringify(canonicalFromProps(props)));
|
|
122
|
+
|
|
123
|
+
export const writeBody = (props: BaoKubernetesRoleProps): Record<string, unknown> => ({
|
|
124
|
+
alias_name_source: props.aliasNameSource,
|
|
125
|
+
audience: props.audience ?? '',
|
|
126
|
+
bound_service_account_names: [...props.boundServiceAccountNames],
|
|
127
|
+
bound_service_account_namespace_selector: props.boundServiceAccountNamespaceSelector ?? '',
|
|
128
|
+
bound_service_account_namespaces: [...(props.boundServiceAccountNamespaces ?? [])],
|
|
129
|
+
...tokenBody(props),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const NAME = /^[a-z0-9_](?:[a-z0-9_.-]*[a-z0-9_])?$/;
|
|
133
|
+
|
|
134
|
+
const starMixed = (values: readonly string[]) => values.length > 1 && values.includes('*');
|
|
135
|
+
|
|
136
|
+
export const problems = (props: BaoKubernetesRoleProps): readonly string[] => {
|
|
137
|
+
const found = [...tokenProblems(props)];
|
|
138
|
+
const namespaces = props.boundServiceAccountNamespaces ?? [];
|
|
139
|
+
const selector = (props.boundServiceAccountNamespaceSelector ?? '').trim();
|
|
140
|
+
if (!NAME.test(props.name))
|
|
141
|
+
found.push(`name \`${props.name}\` must be lower case (stored folded)`);
|
|
142
|
+
if (props.boundServiceAccountNames.length === 0) found.push('boundServiceAccountNames is empty');
|
|
143
|
+
if (starMixed(props.boundServiceAccountNames)) found.push('`*` mixed with other account names');
|
|
144
|
+
if (namespaces.length === 0 && selector === '') {
|
|
145
|
+
found.push('boundServiceAccountNamespaces is empty and no namespace selector is set');
|
|
146
|
+
}
|
|
147
|
+
if (starMixed(namespaces)) found.push('`*` mixed with other namespaces');
|
|
148
|
+
return found;
|
|
149
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A role on a `kubernetes` auth mount — `auth/<mount>/role/<name>`: which service accounts may log
|
|
3
|
+
* in and what their token carries. METADATA ONLY. The machine-access plan gives k8s pods this
|
|
4
|
+
* instead of AppRole: one role per namespace/service account.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ NO SECRET IN PROPS OR ATTRIBUTES — see policy.ts. The mount's own config (the cluster's CA and
|
|
7
|
+
* the token reviewer JWT) is not declared here, and the reviewer JWT must never be.
|
|
8
|
+
* ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED — pathRoleDelete removes the storage entry
|
|
9
|
+
* and nothing else (path_role.go:239-256).
|
|
10
|
+
*
|
|
11
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first (the new
|
|
12
|
+
* path cannot collide). Under the default `retain` the old role stays live until removed by hand.
|
|
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 { baoDelete } from './bao-http.ts';
|
|
20
|
+
import {
|
|
21
|
+
type BaoKubernetesRoleAttributes,
|
|
22
|
+
type BaoKubernetesRoleProps,
|
|
23
|
+
attributesOf,
|
|
24
|
+
matches,
|
|
25
|
+
mountOf,
|
|
26
|
+
problems,
|
|
27
|
+
rolePath,
|
|
28
|
+
writeBody,
|
|
29
|
+
} from './kubernetes-role-form.ts';
|
|
30
|
+
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
31
|
+
|
|
32
|
+
export type {
|
|
33
|
+
BaoKubernetesAliasSource,
|
|
34
|
+
BaoKubernetesRoleAttributes,
|
|
35
|
+
BaoKubernetesRoleProps,
|
|
36
|
+
} from './kubernetes-role-form.ts';
|
|
37
|
+
|
|
38
|
+
export interface BaoKubernetesRole extends Resource<
|
|
39
|
+
'Bao.KubernetesRole',
|
|
40
|
+
BaoKubernetesRoleProps,
|
|
41
|
+
BaoKubernetesRoleAttributes,
|
|
42
|
+
never,
|
|
43
|
+
HttpClient.HttpClient
|
|
44
|
+
> {}
|
|
45
|
+
|
|
46
|
+
export const BaoKubernetesRole = Resource<BaoKubernetesRole>('Bao.KubernetesRole', {
|
|
47
|
+
defaultRemovalPolicy: 'retain',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const kubernetesRoleSpec = (
|
|
51
|
+
props: BaoKubernetesRoleProps,
|
|
52
|
+
): RoleSpec<BaoKubernetesRoleAttributes> => ({
|
|
53
|
+
attributesOf: (live) => attributesOf(props, live),
|
|
54
|
+
body: writeBody(props),
|
|
55
|
+
family: 'Bao.KubernetesRole',
|
|
56
|
+
matches: (attributes) => matches(attributes, props),
|
|
57
|
+
path: rolePath(props),
|
|
58
|
+
problems: problems(props),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const BaoKubernetesRoleProvider = () =>
|
|
62
|
+
Provider.effect(
|
|
63
|
+
BaoKubernetesRole,
|
|
64
|
+
Effect.succeed(
|
|
65
|
+
BaoKubernetesRole.Provider.of({
|
|
66
|
+
/** ⛔ The mount's role listing is not a list of things this owns. */
|
|
67
|
+
list: () => Effect.succeed([]),
|
|
68
|
+
|
|
69
|
+
read: Effect.fn(function* ({ olds }) {
|
|
70
|
+
const found = yield* readRoleAt(kubernetesRoleSpec(olds));
|
|
71
|
+
return found?.attributes;
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
|
|
75
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
76
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
77
|
+
if (mountOf(news) !== output.mount || news.name !== output.name) {
|
|
78
|
+
return { action: 'replace' } as const;
|
|
79
|
+
}
|
|
80
|
+
return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
84
|
+
return yield* reconcileRole(kubernetesRoleSpec(news));
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
/** Idempotent as Alchemy requires — a missing role deletes as success. */
|
|
88
|
+
delete: Effect.fn(function* ({ output }) {
|
|
89
|
+
yield* baoDelete(rolePath(output));
|
|
90
|
+
return undefined;
|
|
91
|
+
}),
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
);
|