Skip to content

Commit df8a3fe

Browse files
feat: per-host SSH credential management + in-place credential editing (#595)
* feat(credentials): add PATCH /credentials/{id} in-place update Adds a true partial-update endpoint for SSH credentials, replacing the frontend's create-then-delete workaround. Omitted secret fields keep the stored ciphertext (no re-entry needed); narrowing auth_method nulls the now-unused secret and revalidates the required secret set. Setting is_default=true on a system credential atomically demotes the prior default. scope/scope_id remain immutable. - credential.Update service method (single-tx, FOR UPDATE, auto-demote) - PatchCredentialByID handler (credential:write, CredentialUpdated audit) - openapi CredentialUpdateRequest + regenerated Go/TS stubs - credential.updated audit event - api-credentials spec v1.2.0: C-06..C-08, AC-16..AC-21 (+6 passing tests) - fix post-promotion .gitignore: *credential* caught internal/credential/ * feat(settings): in-place Edit credential via PATCH Replaces the create-then-delete Replace workaround with a real in-place edit against PATCH /credentials/{id}. The 'no PATCH endpoint' warning banner and the orphan/two-phase logic are gone. Secret fields left blank keep the stored ciphertext (labelled 'leave blank to keep current'), so editing name/username/auth_method no longer forces re-entry of the key or password. - ReplaceCredentialModal -> EditCredentialModal (single PATCH call) - credentialEditSchema makes secrets optional (blank = keep) - CredentialsPage: Edit labels, EditCredentialModal wiring - frontend-settings spec v1.9.0 scope bullets * feat(host-detail): per-host credential management + live Reconnect Wires the three deferred host-page controls onto the new credential and discovery endpoints. - HostCredentialModal: shared per-host credential surface. Reads the resolved source (POST /credentials:resolve) and switches a host between the system default and a host-specific override using the tier model: clone the default (keeps the secret), set a different host credential, edit the override in place (PATCH), or revert (DELETE). Mutations gated on credential:write; viewers see the source read-only. - Connectivity card: Auth row now shows the resolved credential name + source tag (was a hardcoded 'system_default'); 'Edit credentials' opens the modal; 'Reconnect' calls POST /hosts/{id}/discovery:run (synchronous OS discovery, bypasses the scan queue, validates the SSH credential; 502 surfaces a credential failure). - EditHostModal: adds a 'Manage SSH credential' link into the same modal. - export shared form pieces from CredentialMutations for reuse. - frontend-host-detail spec v1.6.0: C-11, C-12, AC-40..AC-42 (+3 tests). * docs(changelog): note credential editing + host credential management + Reconnect * style(host-detail): apply prettier formatting
1 parent 088a082 commit df8a3fe

19 files changed

Lines changed: 2055 additions & 537 deletions

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,18 @@ cache/
604604
# applies for genuine build artifacts (dist/, openapi_embed.yaml).
605605
!app/**
606606

607+
# The Go tree was promoted from app/ to the repo root (2026-06-05), so the
608+
# !app/** negation above no longer reaches it. The safety-net name patterns
609+
# (*credential*, *secret*) wrongly catch these Go source package directories —
610+
# they are source code, not secrets. Genuine build artifacts under internal/
611+
# are ignored by their own explicit rules further down (internal/server/
612+
# openapi_embed.yaml, internal/server/spa/), so this negation does not expose
613+
# them.
614+
!internal/credential/
615+
!internal/credential/**
616+
!internal/secretkey/
617+
!internal/secretkey/**
618+
607619
# Configuration files that might contain secrets
608620
*config.local.*
609621
*settings.local.*

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,25 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010

1111
## [Unreleased]
1212

13+
### Added
14+
15+
- SSH credentials can now be edited in place. The Settings credentials page
16+
updates a credential directly instead of deleting and recreating it, so
17+
changing a name, username, or authentication method no longer forces you to
18+
re-enter the key or password. Leave a secret field blank to keep the stored
19+
one.
20+
- Per-host SSH credential management from the host detail page. A host can be
21+
given its own credential, have that credential edited in place, or be reverted
22+
to the workspace default, all from the host Edit dialog and the Connectivity
23+
card's Edit credentials link.
24+
- A Reconnect action on the host Connectivity card runs OS discovery
25+
immediately, ahead of the scan queue, so you can confirm a host is reachable
26+
and its SSH credential works right after changing it.
27+
1328
### Changed
1429

30+
- The host Connectivity card now shows the credential the host actually uses
31+
(its own override or the workspace default) instead of a fixed label.
1532
- Updated the bundled Kensa scan engine and rule corpus to v0.5.0. v0.5.0 adds
1633
native sudo-with-password support for hosts where passwordless sudo is
1734
disallowed (a common CIS/STIG control); the change is backward-compatible and

api/openapi.yaml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,47 @@ paths:
658658
content:
659659
application/json:
660660
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
661+
patch:
662+
operationId: patchCredentialByID
663+
summary: Update a credential in place
664+
description: >
665+
Partial update of an existing active credential. Only the fields
666+
present in the body change; omitted fields are left untouched.
667+
Secret material (password, private_key, private_key_passphrase)
668+
is re-encrypted only when a non-empty value is supplied — omitting
669+
a secret keeps the stored ciphertext, so metadata can be edited
670+
without re-entering the key or password. scope and scope_id are
671+
immutable. Setting is_default=true on a system credential atomically
672+
demotes the previous system default.
673+
x-required-permission: credential:write
674+
parameters:
675+
- {name: id, in: path, required: true, schema: {type: string, format: uuid}}
676+
requestBody:
677+
required: true
678+
content:
679+
application/json:
680+
schema: {$ref: '#/components/schemas/CredentialUpdateRequest'}
681+
responses:
682+
'200':
683+
description: Updated credential metadata
684+
content:
685+
application/json:
686+
schema: {$ref: '#/components/schemas/CredentialResponse'}
687+
'400':
688+
description: Validation failure (unknown auth_method, missing required secret)
689+
content:
690+
application/json:
691+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
692+
'404':
693+
description: Credential not found or already soft-deleted
694+
content:
695+
application/json:
696+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
697+
'409':
698+
description: is_default=true collides with an existing system default
699+
content:
700+
application/json:
701+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
661702
delete:
662703
operationId: deleteCredentialByID
663704
summary: Soft-delete a credential
@@ -4030,6 +4071,23 @@ components:
40304071
private_key_passphrase: {type: string}
40314072
is_default: {type: boolean}
40324073

4074+
# Partial update shape. Every field is optional; only the fields
4075+
# present are changed. Omitting a secret keeps the stored ciphertext,
4076+
# so metadata can be edited without re-entering the key or password.
4077+
# scope and scope_id are intentionally absent — a credential's scope
4078+
# is immutable.
4079+
CredentialUpdateRequest:
4080+
type: object
4081+
properties:
4082+
name: {type: string, minLength: 1, maxLength: 256}
4083+
description: {type: string, maxLength: 1024}
4084+
username: {type: string, minLength: 1, maxLength: 256}
4085+
auth_method: {type: string, enum: [ssh_key, password, both]}
4086+
password: {type: string, description: "Re-encrypts and replaces the stored password when non-empty; omit to keep the current secret"}
4087+
private_key: {type: string, description: "Re-encrypts and replaces the stored key when non-empty; omit to keep the current secret"}
4088+
private_key_passphrase: {type: string, description: "Re-encrypts and replaces the stored passphrase when non-empty; omit to keep the current secret"}
4089+
is_default: {type: boolean, description: "Setting true on a system credential atomically demotes the previous system default"}
4090+
40334091
# Clone target shape. The source credential's secret material is
40344092
# inherited verbatim (no plaintext crosses the wire); only the new
40354093
# scope, scope_id, name, and is_default are operator-controlled.

audit/events.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,16 @@ events:
455455
scope: {type: string, enum: [system, host]}
456456
auth_method: {type: string, enum: [ssh_key, password, both]}
457457

458+
- code: credential.updated
459+
severity: info
460+
detail_schema:
461+
type: object
462+
properties:
463+
credential_id: {type: string}
464+
scope: {type: string, enum: [system, host]}
465+
auth_method: {type: string, enum: [ssh_key, password, both]}
466+
secret_rotated: {type: boolean}
467+
458468
- code: credential.deleted
459469
severity: warning
460470
detail_schema:

frontend/src/api/schema.d.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,11 @@ export interface paths {
518518
delete: operations["deleteCredentialByID"];
519519
options?: never;
520520
head?: never;
521-
patch?: never;
521+
/**
522+
* Update a credential in place
523+
* @description Partial update of an existing active credential. Only the fields present in the body change; omitted fields are left untouched. Secret material (password, private_key, private_key_passphrase) is re-encrypted only when a non-empty value is supplied — omitting a secret keeps the stored ciphertext, so metadata can be edited without re-entering the key or password. scope and scope_id are immutable. Setting is_default=true on a system credential atomically demotes the previous system default.
524+
*/
525+
patch: operations["patchCredentialByID"];
522526
trace?: never;
523527
};
524528
"/api/v1/credentials/{id}:clone": {
@@ -2715,6 +2719,21 @@ export interface components {
27152719
private_key_passphrase?: string;
27162720
is_default?: boolean;
27172721
};
2722+
CredentialUpdateRequest: {
2723+
name?: string;
2724+
description?: string;
2725+
username?: string;
2726+
/** @enum {string} */
2727+
auth_method?: "ssh_key" | "password" | "both";
2728+
/** @description Re-encrypts and replaces the stored password when non-empty; omit to keep the current secret */
2729+
password?: string;
2730+
/** @description Re-encrypts and replaces the stored key when non-empty; omit to keep the current secret */
2731+
private_key?: string;
2732+
/** @description Re-encrypts and replaces the stored passphrase when non-empty; omit to keep the current secret */
2733+
private_key_passphrase?: string;
2734+
/** @description Setting true on a system credential atomically demotes the previous system default */
2735+
is_default?: boolean;
2736+
};
27182737
CredentialCloneRequest: {
27192738
/** @enum {string} */
27202739
scope: "system" | "host";
@@ -4631,6 +4650,59 @@ export interface operations {
46314650
};
46324651
};
46334652
};
4653+
patchCredentialByID: {
4654+
parameters: {
4655+
query?: never;
4656+
header?: never;
4657+
path: {
4658+
id: string;
4659+
};
4660+
cookie?: never;
4661+
};
4662+
requestBody: {
4663+
content: {
4664+
"application/json": components["schemas"]["CredentialUpdateRequest"];
4665+
};
4666+
};
4667+
responses: {
4668+
/** @description Updated credential metadata */
4669+
200: {
4670+
headers: {
4671+
[name: string]: unknown;
4672+
};
4673+
content: {
4674+
"application/json": components["schemas"]["CredentialResponse"];
4675+
};
4676+
};
4677+
/** @description Validation failure (unknown auth_method, missing required secret) */
4678+
400: {
4679+
headers: {
4680+
[name: string]: unknown;
4681+
};
4682+
content: {
4683+
"application/json": components["schemas"]["ErrorEnvelope"];
4684+
};
4685+
};
4686+
/** @description Credential not found or already soft-deleted */
4687+
404: {
4688+
headers: {
4689+
[name: string]: unknown;
4690+
};
4691+
content: {
4692+
"application/json": components["schemas"]["ErrorEnvelope"];
4693+
};
4694+
};
4695+
/** @description is_default=true collides with an existing system default */
4696+
409: {
4697+
headers: {
4698+
[name: string]: unknown;
4699+
};
4700+
content: {
4701+
"application/json": components["schemas"]["ErrorEnvelope"];
4702+
};
4703+
};
4704+
};
4705+
};
46344706
postCredentialClone: {
46354707
parameters: {
46364708
query?: never;

frontend/src/components/hosts/EditHostModal.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ interface Props {
5353
open: boolean;
5454
onClose: () => void;
5555
host: EditableHost;
56+
/**
57+
* When provided, renders a "Manage SSH credential" link that hands off
58+
* to the per-host credential modal. The host record itself stores no
59+
* credential (api-credentials uses a scope/scope_id tier model), so
60+
* credential editing lives in its own modal.
61+
*/
62+
onManageCredential?: () => void;
5663
}
5764

5865
function splitTags(csv: string | undefined): string[] {
@@ -63,7 +70,7 @@ function splitTags(csv: string | undefined): string[] {
6370
.filter((t) => t.length > 0);
6471
}
6572

66-
export function EditHostModal({ open, onClose, host }: Props) {
73+
export function EditHostModal({ open, onClose, host, onManageCredential }: Props) {
6774
const queryClient = useQueryClient();
6875
const [serverError, setServerError] = useState<string | null>(null);
6976
const { register, handleSubmit, formState, reset } = useForm<EditForm>({
@@ -197,6 +204,26 @@ export function EditHostModal({ open, onClose, host }: Props) {
197204
<input type="text" {...register('username')} style={inputStyle} />
198205
</FormField>
199206

207+
{onManageCredential && (
208+
<div style={{ margin: '-4px 0 14px' }}>
209+
<button
210+
type="button"
211+
onClick={onManageCredential}
212+
disabled={submitting}
213+
style={{
214+
background: 'transparent',
215+
border: 0,
216+
padding: 0,
217+
color: 'var(--ow-info)',
218+
fontSize: 12,
219+
cursor: submitting ? 'default' : 'pointer',
220+
}}
221+
>
222+
Manage SSH credential (key or password) →
223+
</button>
224+
</div>
225+
)}
226+
200227
<FormField
201228
label="Tags"
202229
hint="Comma-separated. Leave blank to clear all tags."

0 commit comments

Comments
 (0)