feat(deploy): add break-glass Operator account provisioning
Add the insert-only Operator-creation path to the break-glass console (felis breakGlass). An Operator is an additional staff admin: role=admin with must_change_password=true, identical in shape to the Owner, since Felis has no separate operator DB role (migration 0003). Unlike the Owner upsert, provisioning is insert-only -- a username already taken returns ErrConflict (ON CONFLICT DO NOTHING + zero RowsAffected) rather than silently resetting a live account, so adding an Operator can never clobber the Owner's or another Operator's credential. A typed password is used as-is; an empty one is replaced with a generated one-time credential returned for display. Operator-add does not touch local_auth_enabled -- that global gate belongs to the Owner thread alone. Accountability is recorded best-effort under a break_glass.operator_create audit action, written only after a successful provision. The TUI menu router that reaches this path is deferred; this lands the fully unit-testable logic layer (provisionOperator, performAddOperator, auditAddOperator) with the PGRepo insert kept integration-only.
This commit is contained in:
3 files changed
+351
No files matched your search
@@ -604,6 +604,32 @@ func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email, passwordH
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertOperator mints a NEW Operator (additional staff admin) account
|
||||
// direct-to-Postgres. role is forced to 'admin' — Felis has no separate operator
|
||||
// role, so an Operator is an additional admin row identical in shape to the Owner
|
||||
// (migration 0003). UNLIKE UpsertOwner this is insert-only: a username conflict is
|
||||
// left untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero
|
||||
// RowsAffected, so adding an Operator can never silently reset the Owner's or
|
||||
// another Operator's credential. The empty email is stored as NULL.
|
||||
func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
||||
res, err := p.db.ExecContext(ctx,
|
||||
`INSERT INTO users (id, username, email, role, password_hash, must_change_password)
|
||||
VALUES ($1, $2, NULLIF($3, ''), 'admin', $4, $5)
|
||||
ON CONFLICT (username) DO NOTHING`,
|
||||
id, username, email, passwordHash, mustChange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPassword stores a new hash and clears must_change_password (the panel
|
||||
// change-password flow). ErrNotFound when no row matches so a stale session
|
||||
// cannot silently no-op the change.
|
||||
|
||||
Reference in new issue
Block a user