portal-mojo 0.2.19 → 0.2.20
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/docs/admin-credentials.md +27 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns.md +16 -0
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-modals.md +28 -0
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/collection-select.md +14 -0
- package/package.json +1 -1
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +7 -1
- package/src/admin/dns/DomainsPage.tsx +6 -2
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/operations.ts +6 -4
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/client/auth.ts +77 -25
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/index.ts +2 -2
- package/src/client/mock.ts +233 -6
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/ui/CollectionSelect.tsx +30 -8
|
@@ -206,3 +206,30 @@ See [Admin modal policy](admin-modals.md) for header actions, content-only
|
|
|
206
206
|
sections, supported lifecycle fields and retained irreversible exceptions.
|
|
207
207
|
Group API keys and webhook subscriptions deactivate/reactivate; their built-in
|
|
208
208
|
Delete controls are removed. Reveal, secret rotation and cache boundaries remain.
|
|
209
|
+
|
|
210
|
+
## Protected ACME consumer purpose
|
|
211
|
+
|
|
212
|
+
Literal deployment operators can open **Create ACME consumer key** from the
|
|
213
|
+
existing fixed-Group or cross-Group API-key inventory. This lazy form resolves
|
|
214
|
+
the selected original hub Group through the private shared-validation recipient
|
|
215
|
+
endpoint. It displays the actual active Group name/UUID, the named ACME validation
|
|
216
|
+
consumer purpose and the fixed `dnsman_acme_federation` consequence. A tenant or
|
|
217
|
+
acting user cannot substitute for the original recipient. It has no freeform
|
|
218
|
+
permission or JSON input. The server rechecks Group activity, expected label and
|
|
219
|
+
protected grant at issuance and rejects key-backed sessions.
|
|
220
|
+
|
|
221
|
+
Issue is separately confirmed, single-flight and session-bound. Its response is
|
|
222
|
+
split before presentation: safe key metadata may be displayed; the transient
|
|
223
|
+
token stays outside Query/Mutation caches, URL/history and storage. Closing the
|
|
224
|
+
native token dialog drops that display. The existing explicit, audited
|
|
225
|
+
`/api/group/apikey/<id>?graph=token` reveal remains the recovery route. A timeout
|
|
226
|
+
may follow successful issuance: inspect the exact Group's recent inventory and
|
|
227
|
+
use audited reveal before creating another successor. The form blocks automatic
|
|
228
|
+
reissue and never retries after fresh authentication.
|
|
229
|
+
|
|
230
|
+
Create a replacement, enter it normally in the receiving deployment's protected
|
|
231
|
+
connection form, Preview/Publish, Apply and verify every expected node. Only then
|
|
232
|
+
explicitly review/deactivate the old hub key using existing key controls. This
|
|
233
|
+
form neither rotates nor retires keys, grants allocation trust, activates hub
|
|
234
|
+
sharing or asserts cross-deployment verification. Existing in-place key rotation
|
|
235
|
+
semantics remain unchanged and can invalidate the previous token immediately.
|
|
@@ -120,3 +120,163 @@ npm run verify:admin-dns-records
|
|
|
120
120
|
|
|
121
121
|
Live verification is read-only. Do not request or revoke a real certificate
|
|
122
122
|
without separate operator authorization.
|
|
123
|
+
|
|
124
|
+
## Shared validation setup
|
|
125
|
+
|
|
126
|
+
Use **Domains → Shared validation setup**, or **Set up shared validation** in a
|
|
127
|
+
group-managed Domain or Certificate header menu. Domain **Validation** and
|
|
128
|
+
Certificate **Delegation** show the same read-only facts. No new route or sidebar
|
|
129
|
+
resource is added. The flow never reads certificate material.
|
|
130
|
+
|
|
131
|
+
1. Select an authorized existing local Domain, or an eligible group and exact
|
|
132
|
+
apex name. Group selection uses `/api/dnsman/delegation/group-choice`, bounded
|
|
133
|
+
`search/start/size` and explicit `?id=` hydration. Product active-group context
|
|
134
|
+
and credential group choices do not authorize shared setup.
|
|
135
|
+
2. Enter the existing public allocation target supplied by its controller and
|
|
136
|
+
**Inspect plan**. Inspection creates no connection or reference.
|
|
137
|
+
3. **Prepare connection** and confirm the displayed domain/profile/target.
|
|
138
|
+
Preparation pauses direct local validation and renewal until authorization
|
|
139
|
+
and checking finish. Existing valid certificate material remains available.
|
|
140
|
+
4. **Download request** saves `shared-validation-request.json`. Send it to the
|
|
141
|
+
allocation controller using your normal coordination channel. **Share request**
|
|
142
|
+
uses native file sharing when supported; **Copy request** is optional. Download
|
|
143
|
+
stays available after copy/share denial or cancellation. The canonical file is
|
|
144
|
+
at most 2048 UTF-8 bytes and has exactly `version:1`, `consumer_project_uuid`,
|
|
145
|
+
`client_ref`, `domain`, `allocation_ref`, with no wrapper, key, token or scope.
|
|
146
|
+
5. The controller opens their deployment’s **Domains → Shared validation setup →
|
|
147
|
+
Controller review**, checks access and uses **Import request**. There is no JSON
|
|
148
|
+
editor or controller URL. Empty, oversized, malformed, noncanonical or extra-field
|
|
149
|
+
files are refused before installation. Import alone grants nothing. The named
|
|
150
|
+
public fields fill the exact domain query; **Find allocations**, page if needed
|
|
151
|
+
and select the exact existing target. Only a matching request and resolved active
|
|
152
|
+
exact recipient enable **Review grant**, then **Allow issuance** after consent
|
|
153
|
+
names both apex and wildcard authority. No allocation is created.
|
|
154
|
+
6. Back in the project, **Refresh**, **Connect approved validation**, then
|
|
155
|
+
**Check validation**. These are separate deliberate actions. Check requires
|
|
156
|
+
the approved existing one-hop `_acme-challenge.<apex>` CNAME.
|
|
157
|
+
7. **Request wildcard certificate** issues this deployment's own apex-plus-
|
|
158
|
+
wildcard certificate. Existing valid exact-profile certificates remain in
|
|
159
|
+
place; queued work stays queued and due renewal reuses its lineage. Watch
|
|
160
|
+
the local certificate and separate renewal status, or Refresh after timeout.
|
|
161
|
+
|
|
162
|
+
Only global DNS managers (`sys.manage_dns | sys.security`) see local mutation
|
|
163
|
+
controls, and every action also requires the backend's current per-target flag.
|
|
164
|
+
Controller operations additionally require v2 `shared_validation` and
|
|
165
|
+
`can_manage_trust`, then exact-allocation authority. A human controller can have
|
|
166
|
+
`can_consume:false`; local project setup uses the server-held project identity
|
|
167
|
+
independently. Ordinary DNS managers cannot grant themselves trust. Groupless
|
|
168
|
+
house domains are read-only and ineligible in this rollout; direct operation
|
|
169
|
+
remains available and Portal never assigns a group automatically.
|
|
170
|
+
|
|
171
|
+
### Local API and safe state
|
|
172
|
+
|
|
173
|
+
| Method | Local route under `/api/dnsman` | Input |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| GET | `delegation/plan` | `{domain}` or `{group,name}`, optional `allocation_ref` |
|
|
176
|
+
| POST | `delegation/connect` | Same target plus `allocation_ref`, optional explicit `restart:true` |
|
|
177
|
+
| POST | `delegation/check` | `{delegation:<local id>}` |
|
|
178
|
+
| POST | `delegation/request` | `{delegation:<local id>}`; no custom SANs, force or keys |
|
|
179
|
+
| GET | `delegation/group-choice` | `id` or bounded `search/start/size` |
|
|
180
|
+
|
|
181
|
+
All setup actions return Readiness v1. Portal strictly projects only public
|
|
182
|
+
metadata and validates version, selected identity, source/target, fixed profile,
|
|
183
|
+
booleans and enums. Expected 409s can include the same safe readiness DTO.
|
|
184
|
+
Unknown containers and provider error text are discarded before state. Plans,
|
|
185
|
+
descriptors and controller identities are modal-local; no draft is persisted or
|
|
186
|
+
written to URLs, diagnostics or logs. Writes are imperative and non-retrying,
|
|
187
|
+
freeze the scope, recheck auth/session/permission before send, and reject late
|
|
188
|
+
responses after scope loss. After every attempted write, safe Domain,
|
|
189
|
+
Certificate and legacy delegation caches are invalidated and plan/trust is
|
|
190
|
+
refreshed. Ambiguous results require successful Refresh before another write.
|
|
191
|
+
A 440 prompts authentication but never automatically repeats the operation.
|
|
192
|
+
Every explicit session replacement, including the same user signing in again,
|
|
193
|
+
clears controller capabilities, recipients and participant facts. Check controller
|
|
194
|
+
access and select an authorized allocation again. A new grant also requires a matching imported request and freshly resolved active recipient.
|
|
195
|
+
Closing a view cannot cancel a write already submitted.
|
|
196
|
+
|
|
197
|
+
Controller POSTs use only the local `/api/dnsman/acme` endpoints:
|
|
198
|
+
`capabilities {}`, `allocation/list {domain,cursor?}`,
|
|
199
|
+
`trust/list {allocation_ref,cursor?}`,
|
|
200
|
+
`trust/recipient {allocation_ref,consumer_project_uuid}`,
|
|
201
|
+
`trust/grant {connection_request}` and
|
|
202
|
+
`trust/revoke {allocation_ref,participant_ref}`. Each page contains at most 20
|
|
203
|
+
rows. Only a capability-approved controller probes the allocation/trust routes;
|
|
204
|
+
there is no arbitrary project directory, remote hub call, key provisioning or
|
|
205
|
+
consumer/status call from a human JWT. Recipient and allocation authorization
|
|
206
|
+
are repeated server-side on every operation.
|
|
207
|
+
|
|
208
|
+
### Operator setup and mixed versions
|
|
209
|
+
|
|
210
|
+
If the local plan says the hub is unconfigured or misconfigured, a deployment
|
|
211
|
+
operator uses the existing server configuration delivery to set
|
|
212
|
+
`DNSMAN_ACME_HUB_URL` to the already-used Maestro hub and
|
|
213
|
+
`DNSMAN_ACME_HUB_API_KEY` to the already-provisioned protected project key in
|
|
214
|
+
Django settings, normally `VAR_ROOT/django.conf`. Upgrade the hub first, then
|
|
215
|
+
project API and every certificate worker; drain/restart old issuance workers
|
|
216
|
+
before shared connections are accepted. Restart application and worker
|
|
217
|
+
processes and **Refresh Portal**. A responsive API does not prove that workers
|
|
218
|
+
were restarted; worker version is unknown unless authoritative telemetry exists.
|
|
219
|
+
These settings are static deployment configuration, never DB Setting overrides,
|
|
220
|
+
browser fields, arbitrary per-site URLs or values copied into descriptors.
|
|
221
|
+
|
|
222
|
+
404/405 and unsupported new versions disable only shared setup; existing strict
|
|
223
|
+
DNS config, direct-provider and legacy single-consumer certificate paths retain
|
|
224
|
+
their contracts. 401/403 report access refusal instead of suggesting an upgrade.
|
|
225
|
+
Disabled sharing, unconfigured/auth-rejected/unavailable hubs and unknown proof
|
|
226
|
+
never display setup as complete. Portal does not bypass backend activation.
|
|
227
|
+
|
|
228
|
+
### Repair and revocation
|
|
229
|
+
|
|
230
|
+
ACME validation and traffic routing are separate. The validation CNAME does not
|
|
231
|
+
route apex/www/API/app traffic. Follow the deployment's separate A/AAAA/CNAME
|
|
232
|
+
traffic instructions. Missing, changed or chained validation proof shows the
|
|
233
|
+
expected and bounded observed target and its check time. Ask the domain owner
|
|
234
|
+
to restore the approved one-hop route after checking other consumers. Portal
|
|
235
|
+
does not replace/remove that CNAME or rotate the allocation as a default repair.
|
|
236
|
+
|
|
237
|
+
Controller maintenance starts cold with **Check controller access**, an **Exact
|
|
238
|
+
domain** and **Find allocations**. Native choices show target and owner UUID.
|
|
239
|
+
**Next allocation page** / **First allocation page** and **Next project page** /
|
|
240
|
+
**First project page** navigate bounded 20-row pages. Select an allocation to
|
|
241
|
+
inspect participants, role, state, exact scope and revoked time. No request file
|
|
242
|
+
or recipient lookup is needed for this path. An imported inactive consumer can
|
|
243
|
+
fail recipient lookup while participant inspection, revoke and cleanup remain
|
|
244
|
+
available. Owner participants cannot be revoked. **Refresh trust** checks current
|
|
245
|
+
capabilities; an unsuccessful reconciliation blocks mutation until refresh succeeds.
|
|
246
|
+
|
|
247
|
+
Controller revocation stops future DNS-01 validation for that project. Already-
|
|
248
|
+
issued certificates remain valid until expiry or separate certificate revocation,
|
|
249
|
+
and other trusted projects keep working. A 503 may occur after revocation was
|
|
250
|
+
persisted; refreshed trust can say revoked while DNS cleanup remains unconfirmed.
|
|
251
|
+
Retrying cleanup never regrants the reference. A revoked reference cannot be
|
|
252
|
+
revived: the project must explicitly confirm **Prepare fresh request**, keep
|
|
253
|
+
the existing target, and obtain a new exact controller grant.
|
|
254
|
+
|
|
255
|
+
A valid local certificate with blocked/failed/retrying renewal is shown as
|
|
256
|
+
“valid until …; renewal needs attention.” It is not labeled expired or revoked
|
|
257
|
+
as a repair. Material custody is API-node evidence, not proof that any serving
|
|
258
|
+
host installed that certificate. Shared pending/broken/revoked routing never
|
|
259
|
+
permits fallback TXT writes through the direct provider. Private keys stay in
|
|
260
|
+
each deployment.
|
|
261
|
+
|
|
262
|
+
The mock showcase includes deterministic controller-maintenance pages and an inactive consumer, plus the complete project/controller handshake, viewer,
|
|
263
|
+
revoked, pending/changed/chained DNS, disconnected/unconfigured/disabled hub,
|
|
264
|
+
older API, failed renewal and 390px embedded-content scenarios. Scenario changes
|
|
265
|
+
reset mock shared state; identity changes preserve it. Leaving the demo restores
|
|
266
|
+
its default mock identity and scenario. `verify:admin-dns-shared-validation`
|
|
267
|
+
runs API/projection and React StrictMode mounted contracts. Real browser proof
|
|
268
|
+
remains required for focus, native-dialog stacking, clipboard and both themes.
|
|
269
|
+
|
|
270
|
+
Owner and consumer project identities preserve existing lowercase16hex Group
|
|
271
|
+
IDs as well as the previously supported32hex and canonical UUID forms. They
|
|
272
|
+
are compared exactly and never padded, regenerated or rewritten. Client and
|
|
273
|
+
participant references retain their strict UUID validation; a short project ID
|
|
274
|
+
cannot stand in for either reference.
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
Shared setup prerequisites now link literal deployment operators to the actual
|
|
278
|
+
mount-aware Fleet shared-validation connection topic. It owns named approved
|
|
279
|
+
profiles, write-only protected keys, expected/observed identity and revision-bound
|
|
280
|
+
Preview/Publish/Apply. Ordinary DNS managers receive a concrete domain, next
|
|
281
|
+
action and checked-time handoff with no private inputs or private API background
|
|
282
|
+
calls. This route does not perform the protected hub activation ceremony.
|
package/docs/admin-dns.md
CHANGED
|
@@ -84,3 +84,19 @@ See [Admin modal policy](admin-modals.md) for header actions, content-only
|
|
|
84
84
|
sections, supported lifecycle fields and retained irreversible exceptions.
|
|
85
85
|
Provider credentials retire/reactivate through `is_active`, keeping history;
|
|
86
86
|
rotation remains write-only and imperative. Built-in credential Delete is absent.
|
|
87
|
+
|
|
88
|
+
## Shared wildcard validation
|
|
89
|
+
|
|
90
|
+
The existing Domains header offers **Shared validation setup** to global DNS
|
|
91
|
+
managers even when server setup needs an operator. Domain detail includes
|
|
92
|
+
read-only **Validation**; its header action and Certificate Delegation compose
|
|
93
|
+
the same focused flow. See [the human walkthrough and exact
|
|
94
|
+
contracts](admin-dns-certificates.md#shared-validation-setup).
|
|
95
|
+
|
|
96
|
+
Shared setup uses independent Readiness v1/hub v2 adapters. It never changes the
|
|
97
|
+
strict legacy `/config` response contract. A controller grant is explicit
|
|
98
|
+
apex/wildcard certificate issuance authority; local DNS management alone cannot
|
|
99
|
+
supply it. Only public five-field connection requests are copied. No private
|
|
100
|
+
key, PEM, DNS credential or protected hub key is requested, read, persisted or
|
|
101
|
+
logged. Deployment operators deliver static hub settings server-side and restart
|
|
102
|
+
API/workers. Validation CNAME routing remains separate from traffic routing.
|
|
@@ -7,8 +7,9 @@ page checks `is_superuser === true` before fetching. Backend authority remains
|
|
|
7
7
|
mandatory, including rejection of diagnostic/key-backed sessions and fresh auth.
|
|
8
8
|
|
|
9
9
|
Applications register their schema in django-mojo. The page renders the returned
|
|
10
|
-
section, label, description, default, type and restart requirement;
|
|
11
|
-
|
|
10
|
+
section, label, description, default, type and restart requirement; application schemas remain server-owned. The three managed shared-validation
|
|
11
|
+
connection fields route to the dedicated topic below, rather than appearing as
|
|
12
|
+
independently editable generic fields. Each field defaults to Keep current value. Set value
|
|
12
13
|
and Remove published override are explicit actions. Clearing falls back to base
|
|
13
14
|
configuration/default; it does not promise erasure of a bootstrap secret.
|
|
14
15
|
|
|
@@ -48,3 +49,98 @@ example. Mock Portal uses representative registered settings and an offline node
|
|
|
48
49
|
Django receives this page only after a canonical Portal artifact is built and
|
|
49
50
|
vendored; follow `docs/admin-artifact.md` and the Django vendor tool. Ordinary
|
|
50
51
|
Vite build output is not the canonical artifact.
|
|
52
|
+
|
|
53
|
+
## Shared validation connection
|
|
54
|
+
|
|
55
|
+
Operations → Fleet Configuration → Shared validation connection is a private, lazy
|
|
56
|
+
route. It requires a current literal superuser; the server additionally requires
|
|
57
|
+
an active interactive session, same-origin writes and fresh authentication.
|
|
58
|
+
Key-backed sessions are rejected by the server even when their user is an owner.
|
|
59
|
+
|
|
60
|
+
On the validation hub, update every expected node using the existing Deployments
|
|
61
|
+
controls, then use **Enable shared validation** once. The compact hub section is
|
|
62
|
+
independent of consumer connection configuration and uses the same Portal login,
|
|
63
|
+
fresh authentication and native confirmation as the existing page. No separate
|
|
64
|
+
operator service, login or wizard is required. Missing or incompatible deployment
|
|
65
|
+
proof shows the ordinary prerequisite instead of an enabled action. Each issuer
|
|
66
|
+
keeps its own ACME account, certificates and private keys; allocation trust still
|
|
67
|
+
requires explicit approval in the DNS setup screen. Renewal remains automatic.
|
|
68
|
+
|
|
69
|
+
`GET /api/account/admin/fleet/shared-validation/hub` returns
|
|
70
|
+
`{version:1, enabled, configured, deployment_uuid, can_enable, reason}`.
|
|
71
|
+
The deployment UUID is canonical lowercase with hyphens, unlike consumer project
|
|
72
|
+
UUIDs. `POST` takes only `{deployment_uuid}` after confirmation. The backend
|
|
73
|
+
rechecks the complete deployed roster and framework version atomically before
|
|
74
|
+
enabling. A stale deployment, failed read or fresh-auth refusal requires Refresh
|
|
75
|
+
and another deliberate review; mutations are never replayed. Keep the compatible
|
|
76
|
+
framework on every node after enabling sharing.
|
|
77
|
+
|
|
78
|
+
The panel consumes the private `/api/account/admin/fleet/shared-validation`
|
|
79
|
+
contract. Choose a named approved hub profile, inspect its exact origin and
|
|
80
|
+
original project UUID, then Keep, Replace or Disable the connection. Replacement
|
|
81
|
+
is a normal write-only password field; blank preserves the existing credential.
|
|
82
|
+
The approved catalog fixes the origin. Arbitrary URLs and raw JSON are not inputs.
|
|
83
|
+
|
|
84
|
+
Project identity fields also accept existing lowercase16hex Group IDs, preserving
|
|
85
|
+
the exact value through profiles, observations and protected key issuance. Fleet
|
|
86
|
+
revisions/operation IDs remain32hex and deployment IDs remain canonical UUIDs.
|
|
87
|
+
The legacy identity showcase fixture changes wire data only; no Group identity
|
|
88
|
+
is migrated or rewritten.
|
|
89
|
+
|
|
90
|
+
Preview probes the candidate without saving it. Publication has a separate
|
|
91
|
+
confirmation, frozen against the preview and auth session. Publishing revalidates
|
|
92
|
+
the catalog and expected revision; it does not Apply. Apply has another
|
|
93
|
+
confirmation and targets the exact current revision. Every expected node must
|
|
94
|
+
report installed, restarted and combined request-service/job-engine/scheduler
|
|
95
|
+
health at that revision and the expected topic profile/project identity. Offline,
|
|
96
|
+
draining, stale, partial and missing-node results remain unconfirmed. The UI
|
|
97
|
+
polls at most120 observations and offers deliberate Refresh afterward. Verified
|
|
98
|
+
connection identity with `sharing_ready=false` still needs sharing enabled on the
|
|
99
|
+
approved hub. Refresh the connection after enabling it there.
|
|
100
|
+
|
|
101
|
+
Create the replacement on the approved hub with the named **ACME validation
|
|
102
|
+
consumer** key form. Preview/Publish, Apply, verify every expected node, then
|
|
103
|
+
explicitly review and deactivate the old key in the hub inventory. Issuance,
|
|
104
|
+
publication and successful dispatch never retire keys. Disable masks the whole
|
|
105
|
+
connection tuple and base credential without deleting base settings or revoking
|
|
106
|
+
remote trust; retained/shared identities can make disabling ineligible.
|
|
107
|
+
|
|
108
|
+
Secrets remain in an uncontrolled mounted password input and a short-lived
|
|
109
|
+
imperative request draft. They never enter Query/Mutation caches, URL/history,
|
|
110
|
+
storage, receipts or logs. These new writes call `requestFreshAuth` without
|
|
111
|
+
replaying the attempted action; the existing generic Fleet helpers keep their
|
|
112
|
+
prior behavior. Auth/session change or unmount invalidates drafts and closes
|
|
113
|
+
owned confirmations. Double activation is single-flight. A409 revision/catalog
|
|
114
|
+
conflict or `configuration_busy` requires Refresh and a deliberate retry. An
|
|
115
|
+
uncertain publication or issuance may have persisted; read authoritative status
|
|
116
|
+
or exact Group inventory before another attempt.
|
|
117
|
+
|
|
118
|
+
Unavailable APIs, missing DNSMan/publisher/delegation/schema/enrollment or an empty
|
|
119
|
+
approved catalog render a concrete blocked state. Bootstrap supplies `admin_path`
|
|
120
|
+
and permission facts for existing legacy System Setup, Deployments, topology and
|
|
121
|
+
Fleet Scaling links. Links are same-origin and capability-gated. The backend's
|
|
122
|
+
semantic `capacity` hint maps to the actual registered `#/fleet` route. These
|
|
123
|
+
legacy destinations retain their protected confirmations, policy, release holds
|
|
124
|
+
and IaC restrictions. This React topic reuses those controls without a new
|
|
125
|
+
deployment or root authority service.
|
|
126
|
+
|
|
127
|
+
The Runtime settings showcase mounts the actual connection panel with central
|
|
128
|
+
mock scenarios and operator/ordinary-manager identities. The named
|
|
129
|
+
`verify:admin-shared-validation-config` gate includes private DTO/API and mounted
|
|
130
|
+
StrictMode contracts, and is included once by `verify:admin-contracts`. Browser
|
|
131
|
+
proof remains necessary for real native dialogs, keyboard/focus and390px in both
|
|
132
|
+
themes. Mock receipts are fixtures, never live configuration evidence.
|
|
133
|
+
|
|
134
|
+
The private form uses an inline-size container: fact labels stack above UUIDs and
|
|
135
|
+
revision values below420px of available content, including a narrow embedded
|
|
136
|
+
Admin host. The existing Admin sidebar's Collapse control supplies its normal
|
|
137
|
+
compact host at390px; the topic does not change global navigation behavior.
|
|
138
|
+
|
|
139
|
+
Every successful authoritative refresh invalidates a preview when revision,
|
|
140
|
+
catalog, profile, identity or credential facts change. Late preview/operation
|
|
141
|
+
responses cannot restore spent consent or overwrite a newer observation. After
|
|
142
|
+
the automatic observation limit, explicit Refresh re-observes the tracked exact
|
|
143
|
+
operation and resets the read budget; it never dispatches another Apply. Only
|
|
144
|
+
known healthy report/node statuses with no top-level or node error can turn the
|
|
145
|
+
combined proof green. Apply/read replies remain bound to their exact operation
|
|
146
|
+
ID and requested/tracked revision.
|
|
@@ -35,6 +35,18 @@ existing system permission gate and conditional queries. Parent, child, member
|
|
|
35
35
|
and user navigation opens native detail modals while keeping the directory
|
|
36
36
|
underneath.
|
|
37
37
|
|
|
38
|
+
The Sub-Groups section carries the "Administrators can create sub-groups"
|
|
39
|
+
switch for `sys.groups | sys.manage_groups` holders only; nobody else sees it.
|
|
40
|
+
It reads `metadata.protected.allow_member_subgroups === true` (anything else
|
|
41
|
+
is off) and changes it only through the django-mojo Group action
|
|
42
|
+
`POST /api/group/<id> {"allow_member_subgroups": true|false}`, which answers
|
|
43
|
+
`{"status": true, "allow_member_subgroups": <bool>}`. Never write
|
|
44
|
+
`metadata.protected` directly: that path refuses `manage_groups` admins. The
|
|
45
|
+
action needs django-mojo with #9126; the server refuses a caller without the
|
|
46
|
+
global grant with 403 and a non-boolean with 400. While on, people who manage
|
|
47
|
+
the group as members can create groups directly under it; the setting is not
|
|
48
|
+
inherited by the new groups, and turning it off leaves existing groups alone.
|
|
49
|
+
|
|
38
50
|
The group model and section code are adapted from the reference portal at
|
|
39
51
|
`v0.2.5` commit `93c7fdfeb3096d33010e72bd4483cf9ca6e3d6f5`. The source hashes
|
|
40
52
|
and corresponding package hashes are recorded in
|
package/docs/admin-modals.md
CHANGED
|
@@ -135,3 +135,31 @@ browser interactions for all family bodies, both themes, desktop/narrow,
|
|
|
135
135
|
keyboard navigation and console checks. The lazy Showcase **Admin modal audit**
|
|
136
136
|
demo supplies long-title, rich/zero/dot badges, nested editor, lifecycle-cancel and
|
|
137
137
|
embedded-host cases. Existing ToastHost top-layer visibility work remains separate.
|
|
138
|
+
|
|
139
|
+
## Shared validation confirmations
|
|
140
|
+
|
|
141
|
+
The DNS shared-validation editor freezes the target group/domain, existing
|
|
142
|
+
allocation and session while an operation is pending. Grant additionally freezes
|
|
143
|
+
the imported request, active exact recipient UUID and client reference. Participant
|
|
144
|
+
revoke/cleanup freezes the selected current-page participant independently of any
|
|
145
|
+
request or recipient lookup; cleanup retry leaves the reference revoked.
|
|
146
|
+
Its owner AbortSignal closes only its confirmation on unmount, scope loss or
|
|
147
|
+
auth/session replacement; submitted writes can still persist. Escape/backdrop
|
|
148
|
+
and Close are unavailable while the outer operation is pending.
|
|
149
|
+
|
|
150
|
+
Prepare explains the pause of direct validation/renewal while valid material
|
|
151
|
+
remains available. Grant names the exact project and apex/wildcard issuance
|
|
152
|
+
authority and independent private-key custody. Revoke explains that future
|
|
153
|
+
validation stops, already-issued certificates remain valid and other consumers
|
|
154
|
+
keep working. Revoked references require a fresh, explicitly prepared request.
|
|
155
|
+
No confirmation is inferred from a copied descriptor. Fresh authentication
|
|
156
|
+
prompts without replay; every submitted result reconciles before another write.
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
The protected connection topic separately confirms Publish and Apply, naming
|
|
160
|
+
profile/project and exact revision consequences. The ACME consumer key form
|
|
161
|
+
confirms the current original Group name/UUID and fixed protected purpose before
|
|
162
|
+
issuance; its token display is transient and the existing audited reveal is
|
|
163
|
+
available for recovery. Every owned nested dialog receives the auth-owner abort
|
|
164
|
+
signal. Session replacement, scope loss and unmount close those dialogs; submitted
|
|
165
|
+
writes may still persist and are never replayed after fresh authentication.
|
package/docs/auth-pages.md
CHANGED
|
@@ -124,12 +124,18 @@ Client surface (`portal-mojo/client`):
|
|
|
124
124
|
- `withFreshAuth(fn)` — run fn; on a 440 prompt, then retry **once** after a
|
|
125
125
|
successful re-login; a dismissed prompt rejects with the ORIGINAL error.
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
127
|
+
In hosted mode (including packaged Admin), the native dialog offers Cancel
|
|
128
|
+
and Continue to sign-in. Continue opens the existing configured sign-in page
|
|
129
|
+
with `force_reauth=1`, so Google-only deployments can use Google. It preserves
|
|
130
|
+
the exact return route and original storage choice. The pending challenge
|
|
131
|
+
resolves false before unloading: after returning, re-read and deliberately
|
|
132
|
+
retry the action. Picking another account starts a new session and screen;
|
|
133
|
+
the former account's operation is never carried over.
|
|
134
|
+
|
|
135
|
+
In explicit in-app/mock mode the modal re-prompts credentials over the current
|
|
136
|
+
screen, pins the current email when available, and keeps the existing
|
|
137
|
+
password/passkey/MFA and storage behavior. Dev affordance:
|
|
138
|
+
`__mojo.requestFreshAuth()` exercises the modal without a 440.
|
|
133
139
|
|
|
134
140
|
Call-site pattern:
|
|
135
141
|
|
|
@@ -172,6 +178,12 @@ bouncer-enforcing deployments.
|
|
|
172
178
|
|
|
173
179
|
## Mock affordances
|
|
174
180
|
|
|
181
|
+
`npm run verify:hosted-reauth` covers the mounted hosted confirmation and
|
|
182
|
+
return/storage paths. For native dialog, responsive and theme checks, run
|
|
183
|
+
`node scripts/serve-hosted-reauth.mjs` and open its printed local URL. The
|
|
184
|
+
fixture uses the actual Portal host/bridge and a simulated Google-only sign-in
|
|
185
|
+
page; it does not prove a live OAuth provider exchange.
|
|
186
|
+
|
|
175
187
|
Under the mock transport the pages show inline dev hints: password `mojo`
|
|
176
188
|
for any seeded user (e.g. `ian@mojoverify.com`); Maya (seeded user 2) requires
|
|
177
189
|
MFA; TOTP `123456`, recovery `mock-recovery`, and SMS `654321`; reset code
|
package/docs/auth.md
CHANGED
|
@@ -102,11 +102,25 @@ its bot-check interstitial). Flow:
|
|
|
102
102
|
`<origin>/auth?redirect=<HASH-FREE current url>`. Hash-free is
|
|
103
103
|
load-bearing: the page string-appends `?auth_code=<code>` to the
|
|
104
104
|
redirect, and a `#` in it would swallow the code.
|
|
105
|
-
2. User signs in
|
|
105
|
+
2. User signs in using that page's configured methods (Google, password,
|
|
106
|
+
passkey, etc.). Same-origin return uses its existing stored session;
|
|
107
|
+
a cross-origin return mints a single-use 60s handoff code
|
|
106
108
|
(`POST /api/auth/handoff`) and navigates back with `?auth_code=`.
|
|
107
109
|
3. `handleAuthCodeFromURL()` (boot) exchanges it (`POST /api/auth/exchange`),
|
|
108
110
|
scrubs the code from the URL synchronously, restores the stashed route.
|
|
109
|
-
|
|
111
|
+
It also restores same-origin returns without a code. Handles a
|
|
112
|
+
hash-embedded `#/route?auth_code=` landing too. Await this at boot before
|
|
113
|
+
creating the router.
|
|
114
|
+
|
|
115
|
+
`redirectToHostedAuth({forceReauth: true})` uses the same page with
|
|
116
|
+
`force_reauth=1`, bypassing its automatic existing-session check. It retains
|
|
117
|
+
the initiating persistent/session-only choice, including when the hosted
|
|
118
|
+
page writes localStorage. `remember` may explicitly supply the choice captured
|
|
119
|
+
when the challenge opened. Tab-local return metadata contains only the route,
|
|
120
|
+
destination, time and storage choice; it is consumed once, expires after 30
|
|
121
|
+
minutes, and applies only at the matching origin/path/query. It never carries
|
|
122
|
+
an operation or establishes freshness. The server still enforces `auth_time`.
|
|
123
|
+
When storage cannot retain the choice, step-up navigation returns false.
|
|
110
124
|
|
|
111
125
|
`hostedAuthUrl()` returns null under the mock transport — there is no page
|
|
112
126
|
to host; the UI should fall back to a dev affordance (`__mojo.login(...)`).
|
|
@@ -130,3 +130,17 @@ too — there is no parallel fetch path.
|
|
|
130
130
|
handed a picker that says to pick a group they can't pick there.
|
|
131
131
|
- Don't wire `onChange` to a per-keystroke autosave expectation — it fires
|
|
132
132
|
on commit (that is the point; see the B3 autosave contract).
|
|
133
|
+
|
|
134
|
+
### Optional ingress projection
|
|
135
|
+
|
|
136
|
+
`sanitizeRow={(row) => publicProjection(row)}` projects list and one-record
|
|
137
|
+
responses before Query cache storage. Such pickers use a separate cache keyed
|
|
138
|
+
by the sanitizer identity, auth UID and session revision; they never scan or
|
|
139
|
+
reuse raw endpoint caches. Keep the callback stable. Default consumers retain
|
|
140
|
+
the existing shared model keys and behavior. The callback must return only the
|
|
141
|
+
approved fields or reject malformed input. Full selected objects also need to
|
|
142
|
+
be projected by the caller before passing them in.
|
|
143
|
+
|
|
144
|
+
An endpoint with query-based hydration should hydrate through its own safe
|
|
145
|
+
`?id=` helper and pass the full selected object; do not pass a bare id that
|
|
146
|
+
would invent a `/<id>` record route.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.20",
|
|
4
4
|
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { requestFreshAuth } from '../../client/runtime';
|
|
4
|
+
import { CollectionSelect, FlatRow, modal } from '../../ui';
|
|
5
|
+
import { SecretBox } from './secret-dialog';
|
|
6
|
+
import { OperatorError, usePrivateOperator, projectIdentity } from '../fleet-configuration/shared-validation-data';
|
|
7
|
+
import { issueAcmeKey, projectKeyGroup, readAcmeRecipient, readKeyGroup, readRecipientKeyInventory, revealRecipientKey, type AcmeRecipient, type KeyGroup, type KeySummary } from './acme-consumer-key';
|
|
8
|
+
import { GroupApiKeyModel, type CredentialGroup } from './models';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export function AcmeConsumerKeyButton({ group }: { group?: CredentialGroup }) {
|
|
12
|
+
const owner = usePrivateOperator();
|
|
13
|
+
return owner.allowed ? <button className="btn btn-compact" onClick={() => void openAcmeConsumerKey(group)}>Create ACME consumer key</button> : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function openAcmeConsumerKey(group?: CredentialGroup) {
|
|
17
|
+
let pending = false;
|
|
18
|
+
return modal.open((close) => <AcmeConsumerKeyDialog group={group} close={() => close(null)} onBusy={(busy) => { pending = busy; }} />, { size: 'lg', canDismiss: () => !pending });
|
|
19
|
+
}
|
|
20
|
+
export function AcmeConsumerKeyDialog({ group: fixedGroup, close, onBusy }: { group?: CredentialGroup; close: () => void; onBusy?: (busy: boolean) => void }) {
|
|
21
|
+
const owner = usePrivateOperator(), qc = useQueryClient();
|
|
22
|
+
const [group, setGroup] = useState<KeyGroup | null>(null), [recipient, setRecipient] = useState<AcmeRecipient | null>(null);
|
|
23
|
+
const [inventory, setInventory] = useState<KeySummary[] | null>(null), [blocked, setBlocked] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('');
|
|
24
|
+
const name = useRef<HTMLInputElement>(null), flight = useRef(false), generation = useRef(0), recipientOwner = useRef('');
|
|
25
|
+
const selected = useRef(group?.id); selected.current = group?.id;
|
|
26
|
+
const opener = useRef<HTMLElement | null>(null);
|
|
27
|
+
const pending = (value: boolean) => {
|
|
28
|
+
if (value) opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
29
|
+
flight.current = value; setBusy(value); onBusy?.(value);
|
|
30
|
+
if (!value) { const fence = owner.fence(), target = opener.current; requestAnimationFrame(() => { try { fence.beforeSend(); if (target?.isConnected) target.focus(); } catch { /* Former owner cannot regain focus. */ } }); }
|
|
31
|
+
};
|
|
32
|
+
useEffect(() => () => { generation.current++; onBusy?.(false); }, []);
|
|
33
|
+
useEffect(() => { setRecipient(null); setInventory(null); setBlocked(true); if (name.current) name.current.value = ''; }, [owner.key]);
|
|
34
|
+
const failure = async (reason: unknown) => { const safe = reason instanceof OperatorError ? reason : new OperatorError('unavailable'); setError(safe.message); setBlocked(true); if (safe.kind === 'fresh') await requestFreshAuth(); };
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!fixedGroup || !owner.allowed) return;
|
|
37
|
+
const fence = owner.fence(); void readKeyGroup(fixedGroup.id, fence).then((g) => { fence.beforeSend(); setGroup(g); }).catch((e) => { if (!fence.signal?.aborted) void failure(e); });
|
|
38
|
+
}, [fixedGroup?.id, owner.key]);
|
|
39
|
+
useEffect(() => { setRecipient(null); setInventory(null); setError(''); setBlocked(false); generation.current++; }, [group?.id]);
|
|
40
|
+
const refresh = async () => {
|
|
41
|
+
if (flight.current || !group || !owner.allowed) return;
|
|
42
|
+
const fence = owner.fence(), id = group.id, run = ++generation.current; pending(true); setError(''); setRecipient(null);
|
|
43
|
+
try {
|
|
44
|
+
const current = await readKeyGroup(id, fence); fence.beforeSend();
|
|
45
|
+
if (selected.current !== id || generation.current !== run) return;
|
|
46
|
+
if (!current.uuid) throw new OperatorError('invalid'); projectIdentity(current.uuid);
|
|
47
|
+
const [person, keys] = await Promise.all([readAcmeRecipient(current.uuid, fence), readRecipientKeyInventory(id, fence)]); fence.beforeSend();
|
|
48
|
+
if (selected.current !== id || generation.current !== run) return;
|
|
49
|
+
recipientOwner.current = owner.key; setGroup(current); setRecipient(person); setInventory(keys); setBlocked(false);
|
|
50
|
+
} catch (e) { if (!fence.signal?.aborted && generation.current === run) await failure(e); }
|
|
51
|
+
finally { if (!fence.signal?.aborted) pending(false); }
|
|
52
|
+
};
|
|
53
|
+
const displaySecret = async (value: string, title: string) => {
|
|
54
|
+
const fence = owner.fence(); let secret: string | null = value;
|
|
55
|
+
try { fence.beforeSend(); await modal.open((done) => <div className="modal-pad"><h2 className="modal-title">{title}</h2><p>This transient display is not stored in Portal. A later explicit Token reveal in the key inventory is audited and remains available.</p><SecretBox secret={secret ?? ''} ariaLabel="ACME consumer token" /><p>Enter the replacement normally in the receiving deployment’s protected connection form. Publish, Apply and verify every expected node before explicitly deactivating the old hub key. Issuance alone grants no allocation trust or hub activation.</p><div className="modal-actions"><button className="btn" onClick={() => done(null)}>Close token</button></div></div>, { size: 'lg', signal: fence.signal }); }
|
|
56
|
+
finally { secret = null; value = ''; }
|
|
57
|
+
};
|
|
58
|
+
const issue = async () => {
|
|
59
|
+
if (flight.current || !owner.allowed || recipientOwner.current !== owner.key || !recipient?.can_issue || !group || blocked) return;
|
|
60
|
+
const fence = owner.fence(), person = recipient, id = group.id, keyName = name.current?.value.trim() ?? ''; if (!keyName) { setError('Name this replacement key.'); return; }
|
|
61
|
+
const run = generation.current; pending(true); setError(''); let attempted = false;
|
|
62
|
+
try {
|
|
63
|
+
const confirmed = await modal.open<boolean>((done) => <div className="modal-pad"><h2 className="modal-title">Issue protected ACME consumer key?</h2><div className="modal-message"><p>{person.consumer_label} · <code>{person.consumer_project_uuid}</code></p><p>Key: {keyName}</p><p>Grant this exact original hub project the protected ACME validation consumer purpose. The fixed grant is dnsman_acme_federation, with no linked user or override. This does not grant allocation trust or enable shared validation. The existing key stays active.</p></div><div className="modal-actions"><button className="btn" onClick={() => done(false)}>Cancel</button><button className="btn btn-primary" onClick={() => done(true)}>Issue replacement key</button></div></div>, { size: 'md', signal: fence.signal });
|
|
64
|
+
if (!confirmed) return; fence.beforeSend(); if (selected.current !== id || generation.current !== run) throw new OperatorError('conflict');
|
|
65
|
+
attempted = true; const result = await issueAcmeKey(person, keyName, fence); fence.beforeSend();
|
|
66
|
+
if (selected.current !== id || generation.current !== run) { result.token = ''; return; }
|
|
67
|
+
setNotice(`Key ${result.key.name} issued. The old key was not retired.`);
|
|
68
|
+
try { await displaySecret(result.token, 'ACME consumer key issued'); } finally { result.token = ''; }
|
|
69
|
+
} catch (e) { if (!fence.signal?.aborted) await failure(e); }
|
|
70
|
+
finally {
|
|
71
|
+
if (attempted) { if (name.current) name.current.value = ''; await GroupApiKeyModel.invalidate(qc); if (!fence.signal?.aborted && selected.current === id) { try { const keys = await readRecipientKeyInventory(id, fence); fence.beforeSend(); setInventory(keys); } catch { setBlocked(true); setError('Issuance may have persisted. Refresh the Group key inventory before issuing another key.'); } } }
|
|
72
|
+
if (!fence.signal?.aborted) pending(false);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const reveal = async (row: KeySummary) => {
|
|
76
|
+
if (flight.current || !owner.allowed || recipientOwner.current !== owner.key) return; const fence = owner.fence(); pending(true);
|
|
77
|
+
try { const value = await revealRecipientKey(row.id, fence); fence.beforeSend(); await displaySecret(value, `Audited token — ${row.name}`); }
|
|
78
|
+
catch (e) { if (!fence.signal?.aborted) await failure(e); } finally { if (!fence.signal?.aborted) pending(false); }
|
|
79
|
+
};
|
|
80
|
+
if (!owner.allowed) return <div className="modal-pad"><h2>Protected key access unavailable</h2><p>A current interactive literal superuser is required.</p><button className="btn" onClick={close}>Close</button></div>;
|
|
81
|
+
return <div className="modal-pad shared-validation-operator" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><h2 className="modal-title">Create ACME consumer key</h2><p>Run this form on the approved hub. Choose its original recipient Group by name; a downstream tenant or acting user is not that identity.</p>
|
|
82
|
+
{fixedGroup ? <FlatRow label="Selected hub Group">{group?.name ?? fixedGroup.name}</FlatRow> : <CollectionSelect<KeyGroup> endpoint="/api/group" sanitizeRow={projectKeyGroup} value={group} label="Original hub recipient Group" disabled={busy} onChange={(_id, row) => setGroup(row ?? null)} />}
|
|
83
|
+
{group && <><FlatRow label="Group UUID"><code>{group.uuid ?? 'No valid UUID reported'}</code></FlatRow><button className="btn" disabled={busy} onClick={() => void refresh()}>Resolve recipient and refresh keys</button></>}
|
|
84
|
+
{error && <p className="form-alert" role="alert">{error}</p>}{notice && <p role="status">{notice}</p>}
|
|
85
|
+
{recipient && <div className="dns-shared-request"><FlatRow label="Actual active hub project">{recipient.consumer_label}</FlatRow><FlatRow label="Exact UUID"><code>{recipient.consumer_project_uuid}</code></FlatRow><FlatRow label="Purpose">ACME validation consumer</FlatRow><FlatRow label="Fixed protected grant"><code>{recipient.permission}</code></FlatRow><p>{recipient.grant_explanation}</p><p>Issuing a key does not grant allocation trust or activate the hub.</p><label className="field"><span className="field-label">Replacement key name</span><input className="input" ref={name} maxLength={200} disabled={busy || blocked || !recipient.can_issue} placeholder="Project shared validation" /></label><button className="btn btn-primary" disabled={busy || blocked || !recipient.can_issue} onClick={() => void issue()}>Review protected key issuance</button>{!recipient.can_issue && <p>Protected issuance is unavailable for this operator.</p>}</div>}
|
|
86
|
+
<h3>Recent Group key inventory</h3><p>After an uncertain response, Refresh this inventory and use audited reveal before creating another successor. Up to 100 recent keys are shown; generic key management remains in the existing inventory.</p>{inventory?.map((row) => <div className="dns-shared-request" key={row.id}><strong>{row.name} · #{row.id}</strong><p>{row.is_active ? 'Active' : 'Inactive'}</p><button className="btn" disabled={busy} onClick={() => void reveal(row)}>Reveal audited token</button></div>)}
|
|
87
|
+
<p>Replacement → protected connection Preview/Publish → Apply → verify every expected node → explicitly review/deactivate the old hub key. No key is rotated or retired by this form.</p><div className="modal-actions"><button className="btn" disabled={busy} onClick={close}>{busy ? 'Operation pending…' : 'Close'}</button></div></div>;
|
|
88
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { choice, flag, label, nullable, object, OperatorError, positive, privateCall, projectIdentity, type PrivateScope } from '../fleet-configuration/shared-validation-data';
|
|
2
|
+
import { GroupApiKeyModel } from './models';
|
|
3
|
+
export const ACME_RECIPIENT_ENDPOINT = '/api/account/admin/fleet/shared-validation/recipient';
|
|
4
|
+
export type KeyGroup = { id: number; name: string; uuid: string | null };
|
|
5
|
+
export interface AcmeRecipient { purpose: 'acme_validation_consumer'; consumer_project_uuid: string; consumer_label: string; permission: 'dnsman_acme_federation'; can_issue: boolean; grant_explanation: string }
|
|
6
|
+
export interface KeySummary { id: number; name: string; is_active: boolean }
|
|
7
|
+
export function projectKeyGroup(v: unknown): KeyGroup { const r = object(v); return { id: positive(r.id), name: label(r.name), uuid: r.uuid == null ? null : label(r.uuid, 36) }; }
|
|
8
|
+
export async function readKeyGroup(id: number, scope?: PrivateScope) { const g = projectKeyGroup(await privateCall(`/api/group/${positive(id)}`, null, scope)); if (g.id !== id) throw new OperatorError('invalid'); return g; }
|
|
9
|
+
export function parseAcmeRecipient(v: unknown, expected: string): AcmeRecipient {
|
|
10
|
+
const r = object(v); const out = { purpose: choice(r.purpose, ['acme_validation_consumer']), consumer_project_uuid: projectIdentity(r.consumer_project_uuid), consumer_label: label(r.consumer_label), permission: choice(r.permission, ['dnsman_acme_federation']), can_issue: flag(r.can_issue), grant_explanation: label(r.grant_explanation, 300) };
|
|
11
|
+
if (out.consumer_project_uuid !== expected) throw new OperatorError('invalid'); return out;
|
|
12
|
+
}
|
|
13
|
+
export const readAcmeRecipient = async (consumer: string, scope?: PrivateScope) => parseAcmeRecipient(await privateCall(ACME_RECIPIENT_ENDPOINT, null, scope, { consumer_project_uuid: projectIdentity(consumer) }), consumer);
|
|
14
|
+
export async function issueAcmeKey(recipient: AcmeRecipient, name: string, scope?: PrivateScope): Promise<{ key: KeySummary; token: string }> {
|
|
15
|
+
if (!recipient.can_issue || !name.trim()) throw new OperatorError('denied');
|
|
16
|
+
const r = object(await privateCall(ACME_RECIPIENT_ENDPOINT, { consumer_project_uuid: projectIdentity(recipient.consumer_project_uuid), expected_consumer_label: recipient.consumer_label, label: label(name.trim()), purpose: 'acme_validation_consumer' }, scope));
|
|
17
|
+
let secret = r.token; delete r.token;
|
|
18
|
+
try {
|
|
19
|
+
if (r.purpose !== 'acme_validation_consumer' || r.consumer_project_uuid !== recipient.consumer_project_uuid || r.consumer_label !== recipient.consumer_label) throw new OperatorError('invalid');
|
|
20
|
+
const key = object(r.api_key); if (key.permission !== 'dnsman_acme_federation' || key.is_active !== true || typeof secret !== 'string' || !secret || secret.length > 4096 || /\s/.test(secret)) throw new OperatorError('invalid');
|
|
21
|
+
return { key: { id: positive(key.id), name: label(key.name), is_active: true }, token: secret };
|
|
22
|
+
} finally { secret = null; }
|
|
23
|
+
}
|
|
24
|
+
export async function readRecipientKeyInventory(group: number, scope?: PrivateScope): Promise<KeySummary[]> {
|
|
25
|
+
const rows = await privateCall(GroupApiKeyModel.endpoint, null, scope, { group: positive(group), size: 100, sort: '-created', graph: 'default' });
|
|
26
|
+
if (!Array.isArray(rows) || rows.length > 100) throw new OperatorError('invalid');
|
|
27
|
+
return rows.map((v) => { const r = object(v), g = r.group && typeof r.group === 'object' ? object(r.group).id : r.group; if (g !== group) throw new OperatorError('invalid'); return { id: positive(r.id), name: label(r.name), is_active: flag(r.is_active) }; });
|
|
28
|
+
}
|
|
29
|
+
/** Same explicit audited graph as the existing Token control; no Query cache. */
|
|
30
|
+
export async function revealRecipientKey(id: number, scope?: PrivateScope): Promise<string> {
|
|
31
|
+
const r = object(await privateCall(`${GroupApiKeyModel.endpoint}/${positive(id)}`, null, scope, { graph: 'token' }));
|
|
32
|
+
const value = nullable(r.token, (v) => label(v, 4096)); delete r.token;
|
|
33
|
+
if (!value) throw new OperatorError('unavailable'); return value;
|
|
34
|
+
}
|