4009401f46
One operator action returns a customer to pre-first-install: all operational state dies (offsite repo, PBS namespace+backups, DR recipe, one-time secret, claim state, retained escrow custody); identity + basic config + provenance + events survive. Sits between host delete and customer Delete. - store/customer_reset.go: customer_resets journal, live inventory, ack-gated purge (never touches identity/provenance/events), DeleteClaim. - claim.ResetToUnclaimed: delete claim row -> fresh code next onboarding. - offsite.Deprovision (idempotent) + OffsiteIdentifier + ClearProvisionedDescriptor. - tenantsync.Deprovision + felhom-tenantsync.sh deprovision op (destroys ns + backup groups + token; shared user untouched; idempotent). - web/customer_reset.go: GET reset -> inventory JSON; POST -> orchestration (external teardown FIRST, DB purge LAST; refuse-while-hosts; typed-id + separate escrow ack). Amber RESET card distinct from red Danger-zone Delete. - Red-proofs: ack-gate + partial-failure resumability (both proven red); store ack-gating + journal round-trip; offsite idempotency + descriptor clear; RESET-card render. Green: build + vet + test.
220 lines
9.5 KiB
Go
220 lines
9.5 KiB
Go
// Package claim is the customer-claim password-code engine (v0.50.0, closes DRILL-day0-vm F-4).
|
|
// The hub generates a one-time claim code, emails it (Hungarian) to the REGISTERED customer
|
|
// address, and stores only bcrypt(code) — the plaintext exists solely inside the email send
|
|
// (the retrieval-passphrase custody rule). The controller receives the hash (+ generation)
|
|
// baked into controller.yaml at Day-0 and via the report ACK for live boxes, and gates its
|
|
// dashboard until the customer claims it by setting their own password. Reset rides the same
|
|
// engine: a rotation bumps the generation (single active code) and NEVER clears claimed_at.
|
|
package claim
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// codeWords is the claim/reset code length: 3 Hungarian words (~44 bits with the ~29K list) —
|
|
// dictatable over the phone, and the controller's 5-attempt/15-min lockout makes online guessing
|
|
// infeasible.
|
|
const codeWords = 3
|
|
|
|
// maxResetPerDay is the hub-side cap on controller-forwarded reset requests (per customer).
|
|
const maxResetPerDay = 3
|
|
|
|
// EmailKind selects the Hungarian template for a code delivery.
|
|
type EmailKind string
|
|
|
|
const (
|
|
EmailClaim EmailKind = "claim" // first setup: "Elindult a Felhom szervered"
|
|
EmailReset EmailKind = "reset" // forgotten password
|
|
EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code)
|
|
)
|
|
|
|
// Mailer delivers a claim-arc email. The code is passed through and MUST NOT be persisted or
|
|
// logged by implementations (empty for EmailClaimed). kind is one of the EmailKind constants
|
|
// (plain string in the signature so implementations need no import of this package).
|
|
type Mailer interface {
|
|
SendClaimEmail(kind, customerID, email, domain, code string) error
|
|
}
|
|
|
|
// Engine composes the store, the code generator and the mailer. All methods are idempotent or
|
|
// explicitly rotating; none ever stores or logs a plaintext code.
|
|
type Engine struct {
|
|
Store *store.Store
|
|
Mailer Mailer
|
|
Logger *log.Logger
|
|
}
|
|
|
|
func (e *Engine) logf(f string, a ...any) {
|
|
if e.Logger != nil {
|
|
e.Logger.Printf(f, a...)
|
|
}
|
|
}
|
|
|
|
// newCode generates a fresh code and its bcrypt hash.
|
|
func newCode() (code, hash string, err error) {
|
|
code, err = configgen.RandomPassphrase(codeWords)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("claim: generating code: %w", err)
|
|
}
|
|
h, err := bcrypt.GenerateFromPassword([]byte(code), 10)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("claim: hashing code: %w", err)
|
|
}
|
|
return code, string(h), nil
|
|
}
|
|
|
|
// rotateAndSend rotates the code (generation+1 / create) and emails it. The email failure path
|
|
// keeps the rotated hash (the gate stays armed) and is LOUD: the operator sees emailed_at unset
|
|
// on the customer page and can resend. Returns the new generation.
|
|
func (e *Engine) rotateAndSend(cc *store.CustomerConfig, kind EmailKind) (int, error) {
|
|
code, hash, err := newCode()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
gen, err := e.Store.RotateClaimCode(cc.CustomerID, hash)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("claim: storing code hash: %w", err)
|
|
}
|
|
if cc.Email == "" {
|
|
e.logf("[ERROR] [claim] %s code generated (gen %d) but customer %s has NO registered email — deliver via resend after setting one", kind, gen, cc.CustomerID)
|
|
return gen, fmt.Errorf("claim: customer %s has no registered email", cc.CustomerID)
|
|
}
|
|
if err := e.Mailer.SendClaimEmail(string(kind), cc.CustomerID, cc.Email, cc.Domain, code); err != nil {
|
|
e.logf("[ERROR] [claim] %s code email to customer %s FAILED (gate stays armed; resend from the customer page): %v", kind, cc.CustomerID, err)
|
|
return gen, fmt.Errorf("claim: sending %s email: %w", kind, err)
|
|
}
|
|
if err := e.Store.MarkClaimEmailed(cc.CustomerID); err != nil {
|
|
e.logf("[WARN] [claim] %s code sent to customer %s but emailed_at not recorded: %v", kind, cc.CustomerID, err)
|
|
}
|
|
e.logf("[INFO] [claim] %s code (gen %d) emailed to the registered address of %s", kind, gen, cc.CustomerID)
|
|
return gen, nil
|
|
}
|
|
|
|
// EnsureIssued guarantees a claim row exists for the customer, issuing + emailing the first code
|
|
// when absent. An existing row (any state) is returned untouched — repeated config pulls and
|
|
// reports never rotate or re-send. This is the Day-0 entry point (API config retrieve) and the
|
|
// live-box entry point (first report of an upgrading fleet).
|
|
func (e *Engine) EnsureIssued(cc *store.CustomerConfig) (*store.ClaimState, error) {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
if cs != nil {
|
|
return cs, nil
|
|
}
|
|
if _, err := e.rotateAndSend(cc, EmailClaim); err != nil {
|
|
// The hash (if stored) still arms the gate; surface the error for the caller's log.
|
|
cs, gerr := e.Store.GetClaim(cc.CustomerID)
|
|
if gerr == nil && cs != nil {
|
|
return cs, err
|
|
}
|
|
return nil, err
|
|
}
|
|
return e.Store.GetClaim(cc.CustomerID)
|
|
}
|
|
|
|
// Resend rotates the code and re-sends it — the operator "Kód újraküldése" button. An unclaimed
|
|
// customer gets the claim template; a claimed one gets the reset template (the only code a
|
|
// claimed box can consume is a reset).
|
|
func (e *Engine) Resend(cc *store.CustomerConfig) error {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
kind := EmailClaim
|
|
if cs.Claimed() {
|
|
kind = EmailReset
|
|
}
|
|
_, err = e.rotateAndSend(cc, kind)
|
|
return err
|
|
}
|
|
|
|
// RequestReset handles a controller-forwarded "Elfelejtett jelszó": rate-limited hub-side
|
|
// (maxResetPerDay per customer), then rotates + emails a reset code. The neutral customer-facing
|
|
// response ("ha az e-mail cím regisztrálva van…") is the CALLER's job — this returns real errors
|
|
// for the operator log.
|
|
func (e *Engine) RequestReset(cc *store.CustomerConfig) error {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
if cs == nil {
|
|
// No claim row yet (pre-arc box asking for a reset): issue the first code instead.
|
|
_, err := e.EnsureIssued(cc)
|
|
return err
|
|
}
|
|
count, err := e.Store.BumpResetCount(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: reset limiter: %w", err)
|
|
}
|
|
if count > maxResetPerDay {
|
|
e.logf("[WARN] [claim] reset request for %s REFUSED: daily cap reached (%d/%d)", cc.CustomerID, count, maxResetPerDay)
|
|
return fmt.Errorf("claim: reset cap reached for %s (%d/day)", cc.CustomerID, maxResetPerDay)
|
|
}
|
|
_, err = e.rotateAndSend(cc, EmailReset)
|
|
return err
|
|
}
|
|
|
|
// ReissueForReenroll handles the clean-slate reinstall of a CLAIMED customer (F2, v0.57.0): the box
|
|
// (host + in-guest controller) was wiped and re-enrolls, so the fresh controller has NO password
|
|
// while the hub-side claim is set. This rotates + emails a RESET code (rides Resend's rotation
|
|
// semantics — a single generation bump, single active code) so the customer gets a fresh code
|
|
// automatically instead of hunting for the manual "request new code" button. The new hash reaches
|
|
// the fresh controller through the existing report ACK.
|
|
//
|
|
// No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already
|
|
// owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees
|
|
// single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path,
|
|
// which fires exactly once per reinstall). Returns (generation, reissued, error).
|
|
func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return 0, false, fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
if cs == nil || !cs.Claimed() {
|
|
return 0, false, nil // unclaimed → first-provision path; nothing to re-issue
|
|
}
|
|
gen, err = e.rotateAndSend(cc, EmailReset)
|
|
if err != nil {
|
|
return gen, true, err // reissued=true so the caller records the attempt even on email failure
|
|
}
|
|
e.logf("[INFO] [claim] reset code re-issued (gen %d) for %s on box re-enrollment (clean-slate reinstall)", gen, cc.CustomerID)
|
|
return gen, true, nil
|
|
}
|
|
|
|
// ResetToUnclaimed returns a customer to the pre-first-install (unclaimed, no active code) state — the
|
|
// customer-RESET leg (v0.61.0). It DELETES the claim row so the engine's EnsureIssued mints a FRESH
|
|
// first code (generation reset, unclaimed) on the next onboarding: no parallel "revoked" flag, no stale
|
|
// generation. The active code is invalidated (the row is gone → the gate has no hash) and claimed_at is
|
|
// cleared (gone). Idempotent (a missing row is a no-op).
|
|
func (e *Engine) ResetToUnclaimed(cc *store.CustomerConfig) error {
|
|
if err := e.Store.DeleteClaim(cc.CustomerID); err != nil {
|
|
return fmt.Errorf("claim: reset to unclaimed: %w", err)
|
|
}
|
|
e.logf("[INFO] [claim] reset to unclaimed for %s (customer RESET) — next onboarding mints a fresh code", cc.CustomerID)
|
|
return nil
|
|
}
|
|
|
|
// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
|
|
// email on the unclaimed→claimed transition (idempotent — repeated reports are no-ops).
|
|
func (e *Engine) MarkClaimed(cc *store.CustomerConfig) error {
|
|
transitioned, err := e.Store.MarkClaimed(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: marking claimed: %w", err)
|
|
}
|
|
if !transitioned {
|
|
return nil
|
|
}
|
|
e.logf("[INFO] [claim] customer %s CLAIMED its dashboard (password set by the customer)", cc.CustomerID)
|
|
if cc.Email != "" {
|
|
if err := e.Mailer.SendClaimEmail(string(EmailClaimed), cc.CustomerID, cc.Email, cc.Domain, ""); err != nil {
|
|
e.logf("[WARN] [claim] claimed-confirmation email to %s failed: %v", cc.CustomerID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|