Skip to content

feat: port project_luks_key_event to Go listener (#99)#121

Merged
PaulDotterer merged 2 commits into
mainfrom
feat/99-luks-key-go-projector-v2
May 5, 2026
Merged

feat: port project_luks_key_event to Go listener (#99)#121
PaulDotterer merged 2 commits into
mainfrom
feat/99-luks-key-go-projector-v2

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Fourth port under the projector-migration pattern (#96 canary, #97 totp, #98 lps_password, #99 luks_key). Replaces project_luks_key_event PL/pgSQL with projectors.LuksKeyListener.

Re-opened from closed PR #120 (originally stacked on #119; now rebased onto main after #119 merged). Includes sibling-sweep fixes carried over from #119's CR review: slog.LogValuer masking on LuksKeyRotatedPayload.Passphrase + dropped redundant time.Sleep in IgnoresWrongStreamType.

Four event types:

  • LuksKeyRotated — 3 writes (mark prev not-current, insert new, trim to last 3) wrapped in store.WithTx
  • LuksDeviceKeyRevocationDispatched — UPDATE revocation_status='dispatched'
  • LuksDeviceKeyRevoked — UPDATE revocation_status='success' (event-name vs column-value mismatch matches PL/pgSQL verbatim)
  • LuksDeviceKeyRevocationFailed — UPDATE revocation_status='failed' + revocation_error captured

LuksDeviceKeyRevocationRequested stays a no-op — the deleted PL/pgSQL projector also lacked a case for it.

Behavioural delta

  • Before: PL/pgSQL projector ran inside the AppendEvent transaction. Four event types + event commit atomic.
  • After: Go listener fires post-commit. LuksKeyRotated's 3 writes remain atomic with each other (store.WithTx); revocation events are single UPDATEs and don't need tx wrap. LUKS reads are operator-driven (recover passphrase, audit revocation status) — not hot-path RPCs — so the post-commit gap closes before any human-driven query.

Test plan

Tests written FIRST per saved feedback (TDD).

  • Pure-function: happy paths, default rotation_reason, required-field validation per variant, wrong stream/event type → ErrIgnoredEvent, malformed payload bytes
  • Integration: 4-rotation lifecycle ends with exactly 1 current + 2 history rows (trim-to-3)
  • Integration: per-(action, device_path) scoping — act-A's rotation must not flip act-B's is_current=TRUE row on the same device
  • Integration: revocation flows — Dispatched / Revoked (success) / Failed (with error captured)
  • Integration: LuksDeviceKeyRevocationRequested is ignored (preserves PL/pgSQL no-op behaviour)
  • Integration: wrong-stream-type ignored
  • All tests pass locally (go test ./server/internal/projectors/...)

Refs tracker #107. Closes #99.

Summary by CodeRabbit

  • New Features
    • Implemented LUKS key rotation and revocation handling with per-device/action scoping, history trimming to recent rotations, and revocation status lifecycle (dispatched, success, failed).
  • Tests
    • Added unit and end-to-end tests covering decoding, rotation lifecycle, scoping, and revocation flows.
  • Chores
    • Switched projection execution to the new Go-based listener and added a migration to delegate projector behavior.

Replaces project_luks_key_event PL/pgSQL with
projectors.LuksKeyListener. Fourth port under the projector-migration
pattern (#96 canary, #97 totp, #98 lps_password, #99 luks_key).

Four event types:
  - LuksKeyRotated: 3 writes (mark prev not-current, insert new,
    trim to last 3) wrapped in store.WithTx for inter-write atomicity
  - LuksDeviceKeyRevocationDispatched: UPDATE current row's
    revocation_status='dispatched'
  - LuksDeviceKeyRevoked: UPDATE current row's revocation_status='success'
    (event-name vs column-value mismatch matches PL/pgSQL verbatim)
  - LuksDeviceKeyRevocationFailed: UPDATE revocation_status='failed'
    + revocation_error captured

LuksDeviceKeyRevocationRequested stays a no-op — the deleted PL/pgSQL
projector also lacked a case for it. Requested is a marker the
dispatcher handler appends before enqueueing; the projection only
changes once Dispatched / Revoked / Failed lands.

Stream-key partitioning is (device_id, action_id, device_path) for
LuksKeyRotated (vs LPS's (device_id, username)). Per-action-path
scope test confirms a regression that drops device_path from the
WHERE clause is caught.

Tests follow TDD: written before implementation per saved feedback.
Pure-function suite covers happy paths, default rotation_reason,
required-field validation per variant, wrong stream/event type,
malformed payload. Integration suite covers 4-rotation lifecycle
(trim-to-3), per-action-path scoping, three revocation flows,
LuksDeviceKeyRevocationRequested ignored, wrong-stream-type ignored.

Refs tracker #107.
@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f26ba04b-7180-413f-8fe1-ab00677c6952

📥 Commits

Reviewing files that changed from the base of the PR and between efe05ff and a2384bd.

⛔ Files ignored due to path filters (1)
  • internal/store/generated/luks.sql.go is excluded by !**/generated/**
📒 Files selected for processing (1)
  • internal/store/queries/luks.sql
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/store/queries/luks.sql

📝 Walkthrough

Walkthrough

This PR ports the in-DB PL/pgSQL project_luks_key_event projector to a Go post-commit listener. It adds payload decoders for LUKS key rotation and revocation events, a LuksKeyListener that applies transactional projections (mark-not-current, insert, trim-to-3, and revocation updates), sqlc queries, tests, wiring, and a migration that no-ops the DB projector.

Changes

LUKS Key Projector Migration

Layer / File(s) Summary
Data Shapes & Decoding
internal/projectors/luks_key.go
Added LuksKeyRotatedPayload and LuksRevocationPayload with LogValue() redacting passphrase. Implemented decoders (LuksKeyRotatedFromEvent, LuksRevocationDispatchedFromEvent, LuksRevokedFromEvent, LuksRevocationFailedFromEvent) that validate required fields, default rotation_reason to "scheduled", parse RFC3339/RFC3339Nano timestamps, and wrap errors or return ErrIgnoredEvent for unrelated events.
Core Listener Logic
internal/projectors/luks_key_listener.go
Added LuksKeyListener registered for luks_key stream: routes event types, decodes payloads, ignores unrelated events; for rotations runs a transaction to mark prior keys not-current, insert new key, and trim to last 3; for revocations updates revocation_status, revocation_error, and revocation_at on the current row. Includes stringPtr helper.
Database Operations
internal/store/queries/luks.sql
Added sqlc queries: MarkLuksKeysNotCurrent, InsertLuksKey, TrimLuksKeysToLast3, UpdateLuksKeyRevocationStatus supporting the transactional workflow and scoped updates to current rows.
Migration
internal/store/migrations/020_luks_key_projector_to_go.sql
+goose Up replaces the PL/pgSQL project_luks_key_event(event events) body with a no-op stub (deferring projection to Go listener); +goose Down restores the original PL/pgSQL projector logic.
Integration & Wiring
internal/projectors/wire.go
Registered LuksKeyListener in WireAll using a sub-logger tag luks_key_projector.
Tests & Verification
internal/projectors/luks_key_test.go
Added pure-decoder unit tests (field validation, defaults, timestamp parsing, ErrIgnoredEvent) and Postgres-backed listener tests: rotation lifecycle (trim to 3, one current), per-(action,device_path) scoping, revocation dispatched/success/failed flows, ignored LuksDeviceKeyRevocationRequested, and wrong-stream-type no-op cases.

Sequence Diagram

sequenceDiagram
    actor App as Application
    participant ES as EventStore
    participant Listener as LuksKeyListener
    participant DB as Postgres

    App->>ES: Append LuksKeyRotated / Revocation event
    ES->>Listener: Post-commit listener invoked with event
    activate Listener
    Listener->>Listener: Decode payload (validate, parse timestamps)
    alt Rotation event
        Listener->>DB: BEGIN
        Listener->>DB: MarkLuksKeysNotCurrent(device,action,path)
        Listener->>DB: InsertLuksKey(new current row)
        Listener->>DB: TrimLuksKeysToLast3(device,action,path)
        Listener->>DB: COMMIT
    else Revocation event
        Listener->>DB: UpdateLuksKeyRevocationStatus(device,action, status,error,at) on current row
    end
    deactivate Listener
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hop from SQL to Go today,
Keys rotate and old rows stray,
Three kept close, the rest take flight,
Revocations mark the night—
A tiny rabbit logs delight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: port project_luks_key_event to Go listener (#99)' directly summarizes the primary change: migrating the PL/pgSQL projector to a Go event listener, matching the scope and objectives of the changeset.
Linked Issues check ✅ Passed All acceptance criteria from #99 are satisfied: the Go projector handles all PL/pgSQL event types, comprehensive table-driven tests cover validation/ignored events/revocation flows, the PL/pgSQL function is replaced via migration with a no-op stub, and existing handler tests pass.
Out of Scope Changes check ✅ Passed All changes align with the scope of #99: payload decoding helpers, event listener implementation, SQL queries for projections, migration stub, wiring registration, and comprehensive test coverage. No unrelated changes detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/99-luks-key-go-projector-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@PaulDotterer

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/store/queries/luks.sql (1)

25-29: ⚡ Quick win

Only flip rows that are still current.

This update rewrites every historical row for the partition on each rotation, even though only is_current = TRUE rows need changing. Adding that predicate keeps the result the same while reducing write amplification on the hot path.

Proposed change
 UPDATE luks_keys_projection
 SET is_current = FALSE
 WHERE device_id = $1
   AND action_id = $2
-  AND device_path = $3;
+  AND device_path = $3
+  AND is_current = TRUE;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/queries/luks.sql` around lines 25 - 29, The UPDATE on table
luks_keys_projection is updating every historical row for the partition;
restrict it to only flip currently active rows by adding the is_current
predicate: in the UPDATE that targets device_id = $1, action_id = $2,
device_path = $3, add AND is_current = TRUE so only rows where is_current is
TRUE are set to FALSE, reducing write amplification on the hot path while
preserving semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/projectors/luks_key_listener.go`:
- Around line 57-135: Both error branches currently only log a warning which
lets events commit while the projection stays stale; change them to persist a
durable projection error record and signal retries. In applyLuksKeyRotated
(inside the st.WithTx error handling) and in applyLuksRevocation (inside the
UpdateLuksKeyRevocationStatus error branch) call into the store to insert a
projection error (e.g. via a new or existing method on store.Store / st.Queries
such as InsertProjectionError or RecordProjectionError) with fields: event_id
e.ID, projector "luks_key", event_type e.EventType, device_id payload.DeviceID,
action_id payload.ActionID, device_path payload.DevicePath (if present), and the
error string; if you use st.WithTx in applyLuksKeyRotated ensure the insert
happens in the same transaction or, if recording outside the TX, handle TX
rollback semantics explicitly; also log any failure to persist the projection
error after attempting the insert so persistent-write failures are visible.

---

Nitpick comments:
In `@internal/store/queries/luks.sql`:
- Around line 25-29: The UPDATE on table luks_keys_projection is updating every
historical row for the partition; restrict it to only flip currently active rows
by adding the is_current predicate: in the UPDATE that targets device_id = $1,
action_id = $2, device_path = $3, add AND is_current = TRUE so only rows where
is_current is TRUE are set to FALSE, reducing write amplification on the hot
path while preserving semantics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5412bcb6-7e71-484e-94a9-c3a28b35521e

📥 Commits

Reviewing files that changed from the base of the PR and between cbb5022 and efe05ff.

⛔ Files ignored due to path filters (1)
  • internal/store/generated/luks.sql.go is excluded by !**/generated/**
📒 Files selected for processing (6)
  • internal/projectors/luks_key.go
  • internal/projectors/luks_key_listener.go
  • internal/projectors/luks_key_test.go
  • internal/projectors/wire.go
  • internal/store/migrations/020_luks_key_projector_to_go.sql
  • internal/store/queries/luks.sql

Comment on lines +57 to +135
func applyLuksKeyRotated(ctx context.Context, st *store.Store, logger *slog.Logger, e store.PersistedEvent) {
payload, err := LuksKeyRotatedFromEvent(e)
if err != nil {
if errors.Is(err, ErrIgnoredEvent) {
return
}
logger.Warn("luks_key projector: invalid LuksKeyRotated payload",
"event_id", e.ID, "error", err)
return
}
if err := st.WithTx(ctx, func(q *store.Queries) error {
if err := q.MarkLuksKeysNotCurrent(ctx, db.MarkLuksKeysNotCurrentParams{
DeviceID: payload.DeviceID,
ActionID: payload.ActionID,
DevicePath: payload.DevicePath,
}); err != nil {
return err
}
if err := q.InsertLuksKey(ctx, db.InsertLuksKeyParams{
DeviceID: payload.DeviceID,
ActionID: payload.ActionID,
DevicePath: payload.DevicePath,
Passphrase: payload.Passphrase,
RotatedAt: payload.RotatedAt,
RotationReason: payload.RotationReason,
}); err != nil {
return err
}
return q.TrimLuksKeysToLast3(ctx, db.TrimLuksKeysToLast3Params{
DeviceID: payload.DeviceID,
ActionID: payload.ActionID,
DevicePath: payload.DevicePath,
})
}); err != nil {
logger.Warn("luks_key projector: failed to apply LuksKeyRotated",
"event_id", e.ID,
"device_id", payload.DeviceID,
"action_id", payload.ActionID,
"device_path", payload.DevicePath,
"error", err)
}
}

// applyLuksRevocation is generic across the three revocation event
// types — they all UPDATE the current row's (revocation_status,
// revocation_error, revocation_at). The decoder picks the right
// timestamp key + status value; the writer is identical.
func applyLuksRevocation(
ctx context.Context,
st *store.Store,
logger *slog.Logger,
e store.PersistedEvent,
decode func(store.PersistedEvent) (LuksRevocationPayload, error),
) {
payload, err := decode(e)
if err != nil {
if errors.Is(err, ErrIgnoredEvent) {
return
}
logger.Warn("luks_key projector: invalid revocation payload",
"event_id", e.ID, "event_type", e.EventType, "error", err)
return
}
at := payload.At
if err := st.Queries().UpdateLuksKeyRevocationStatus(ctx, db.UpdateLuksKeyRevocationStatusParams{
DeviceID: payload.DeviceID,
ActionID: payload.ActionID,
RevocationStatus: stringPtr(payload.Status),
RevocationError: payload.Error,
RevocationAt: &at,
}); err != nil {
logger.Warn("luks_key projector: failed to update revocation_status",
"event_id", e.ID,
"event_type", e.EventType,
"device_id", payload.DeviceID,
"action_id", payload.ActionID,
"status", payload.Status,
"error", err)
}

@coderabbitai coderabbitai Bot May 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Persist projector failures somewhere durable.

After 020_luks_key_projector_to_go.sql turns the trigger function into a no-op, these branches become the only failure path for luks_key projection work. Right now they just Warn and return, so a transient DB error leaves the event committed and luks_keys_projection stale with no durable projection_errors record or retry signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/projectors/luks_key_listener.go` around lines 57 - 135, Both error
branches currently only log a warning which lets events commit while the
projection stays stale; change them to persist a durable projection error record
and signal retries. In applyLuksKeyRotated (inside the st.WithTx error handling)
and in applyLuksRevocation (inside the UpdateLuksKeyRevocationStatus error
branch) call into the store to insert a projection error (e.g. via a new or
existing method on store.Store / st.Queries such as InsertProjectionError or
RecordProjectionError) with fields: event_id e.ID, projector "luks_key",
event_type e.EventType, device_id payload.DeviceID, action_id payload.ActionID,
device_path payload.DevicePath (if present), and the error string; if you use
st.WithTx in applyLuksKeyRotated ensure the insert happens in the same
transaction or, if recording outside the TX, handle TX rollback semantics
explicitly; also log any failure to persist the projection error after
attempting the insert so persistent-write failures are visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this for the listener pattern, intentionally — the design choice is logged + reconciler-as-safety-net rather than per-event projection_errors persistence. Documented in project_projector_migration_pattern.md and feedback_post_commit_listener_is_sync.md:

Errors logged + swallowed (post-commit notification contract). The periodic reconciler (1h cycle) is the safety net for any drift.

Reasons not to add projection_errors here:

  1. Replicating the PL/pgSQL EXCEPTION WHEN OTHERS would require a second autocommit DB write that could itself fail. The PL/pgSQL version was atomic with the event commit because it ran inside the trigger transaction; the Go listener is post-commit, so the projection-error insert wouldn't share atomicity with anything.
  2. The reconciler already covers the gap. Within the next reconciliation cycle, the projection re-derives from the event log; a transient DB error during the listener run is fully recovered.
  3. Per-listener persistence becomes a tax across all 11 ports. refactor: port project_security_alert_event projector to Go event listener #96 / refactor: port project_totp_event projector to Go event listener #97 / refactor: port project_lps_password_event projector to Go event listener #98 followed the same pattern; adding it here without doing so for the rest fragments the surface.

If we want a richer durable failure record across all listeners (with retry signal, dead-letter visibility), that's its own design discussion + tracker issue, not a per-port addition.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PaulDotterer, understood — the rationale is sound. The three points together make a coherent case:

Happy to drop this. If a unified durable-failure / dead-letter design ever gets tracked, the same consideration applies across all 11 listener ports equally.

(¬_¬ )ノ🐇


✏️ Learnings added
Learnt from: PaulDotterer
Repo: manchtools/power-manage-server PR: 121
File: internal/projectors/luks_key_listener.go:57-135
Timestamp: 2026-05-04T23:10:54.356Z
Learning: In the power-manage-server projector migration pattern (Go post-commit listeners replacing PL/pgSQL triggers), projection errors are intentionally logged and swallowed rather than persisted to a `projection_errors` table. The periodic reconciler (1h cycle) is the designated safety net for projection drift. This is documented in `project_projector_migration_pattern.md` and `feedback_post_commit_listener_is_sync.md`. Do not flag missing durable projection-error persistence in `internal/projectors/` listeners — the pattern is intentional and consistent across all ported projectors (`#96`, `#97`, `#98`, `#99`).

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

CR quick-win on PR #121: add `is_current = TRUE` predicate to the
UPDATE so historical rows that already have is_current=FALSE aren't
rewritten. Result is identical (FALSE rows stay FALSE) but skips a
write per historical row, reducing write amplification on the hot
rotation path.

The other CR finding (persist projection_errors for durable retry)
is intentionally skipped — the listener pattern is documented as
"log + reconciler-as-safety-net" in project memory; per-listener
projection_errors would replicate the PL/pgSQL EXCEPTION block but
without the in-transaction atomicity guarantee. See PR comment for
full reasoning.
@PaulDotterer

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@PaulDotterer PaulDotterer merged commit 2ab02fa into main May 5, 2026
4 checks passed
@PaulDotterer PaulDotterer deleted the feat/99-luks-key-go-projector-v2 branch May 5, 2026 00:09
PaulDotterer added a commit that referenced this pull request Jun 15, 2026
…uth/api/compliance/dyngroupeval/search/store (WS17b) (#440)

* fix(handler): enforce cert-CN binding on ValidateLuksToken via shared assertDeviceMatchesCert

ValidateLuksToken relayed the caller-supplied device_id to the control proxy
without the mTLS cert-CN check SyncActions enforces, so a compromised agent
presenting device A's certificate could redeem a one-time LUKS token issued
for device B and unlock that device's encrypted volume. Extract the binding
into a shared assertDeviceMatchesCert helper (the only two device-scoped agent
connect RPCs, ValidateLuksToken + SyncActions, now both call it so it cannot
drift) and apply it before the proxy call.

Tests (RED before the guard): cert/request mismatch -> CodePermissionDenied
with the proxy never called; no cert identity under requireTLS ->
CodeUnauthenticated; matching identity proceeds to the proxy.

Refs #439 (WS17b #7)

* fix(search): sanitize agent-controlled fields at the warm/index boundary

Agent-reported hostname / labels / os_* / kernel / agent_version were written
into the Valkey search index verbatim. A malicious or buggy agent could report
a multi-KB value (index bloat) or one embedding ASCII control characters or
<markup> (which a UI rendering raw search-result fields could execute). Add
sanitizeSearchField (drops control chars, angle brackets, invalid UTF-8; caps
length) and apply it on BOTH the warm path (Index.warmDevices) and the live
path (worker.entityFields) so they stay in lockstep. Operator-set fields
(linux_username, actor_id) are trusted-by-policy and deliberately not routed
through it.

Refs #439 (WS17b #17)

* fix(store): make system-role permission seed reconciler-owned (drop drifted SQL literals)

The Admin/User permission arrays were seeded as SQL literals (008_seeds.sql,
patched by 009/010) — a frozen snapshot that drifts from the Go source of truth
(auth.AdminPermissions/DefaultUserPermissions) as permissions are added/renamed.
The Admin literal had already drifted 18 added + 6 renamed permissions behind.
auth.ReconcileSystemRoles overwrites them from the Go sets on every boot, so the
literals are runtime-irrelevant but misleading and undrift-guardable.

Migration 014 blanks the system-role permission arrays so the reconciler is the
single source of truth. Tests:
- store: self-discovering scan that the LAST migration setting each system role's
  permissions leaves them empty (catches a future re-introduced literal); RED
  without 014 (names the drifted role + offending migration).
- auth: repurpose TestUserSeedRole_MatchesDefaultPermissions -> the seed is
  reconciler-owned (empty after migrations); TestReconcileSystemRoles_UpdatesDB
  already pins reconcile fills them to the Go sets.

Refs #439 (WS17b #18)

* refactor(indexer): extract BuildSearchWorkerMux; test forged-key rejection + verify-first

Extract the mux+signer assembly from cmd/indexer/main.go into
search.BuildSearchWorkerMux(rdb, signer, logger), which fails closed when the
signer is nil (empty PM_TASK_SIGNING_KEY) instead of building an unsigned mux
that would accept forged/unsigned search:* tasks from a compromised Valkey
relay. main now calls it.

Tests: a task signed with a DIFFERENT key (forged, not merely absent) is
rejected with asynq.SkipRetry and creates no index hash, for all three task
types (#11, #16); BuildSearchWorkerMux rejects a nil signer (#15) and mounts
VerifyMiddleware ahead of the handlers so an unsigned task is dropped before
any HSET (#15).

Refs #439 (WS17b #11/#15/#16)

* test: self-discovering guards — canary fail-closed, rebuild-applier parity, parity matches-zero, user_role no-op

- system-actor canary (#1): re-key sites by ENCLOSING FUNCTION (stable across
  line edits, not line numbers), FAIL on an unknown or stale site (was advisory
  t.Logf), and add a matches-zero guard. Documented the 12 current
  system-actor write sites with rationales (all legitimate server-owned lifecycle
  / compensating events).
- rebuild parity (#14): add Store.HasRebuildApply + a self-discovering test that
  every AllRebuildTargets entry has a Go applier wired by WireAll (a missing one
  makes RebuildAll a destructive no-op — #125's failure mode); matches-zero guarded.
- permission parity (#19): matches-zero guards in rpcMethodNames (covers all
  three parity tests) and on AllPermissions() so they can't pass vacuously.
- user_role revoke-absent (#21): plant a real unrelated grant and prove it
  SURVIVES the absent revoke — turns a success-only no-op into a scoping invariant
  on the projector feeding the JWT sgrants claim.

Refs #439 (WS17b #1/#14/#19/#21)

* test: dynamicquery in-split metacharacters (#13) + CA identity-from-server (#3)

- dynamicquery (#13): pin the in/notIn comma-split semantics with values that
  carry the separator, empty elements, keyword/quote chars — equals does NOT
  split (a comma-bearing value matches itself) while in() does (so it matches
  neither element); the empty-field-matches-empty-element footgun is documented.
  Sourced from the split semantics, not the implementation.
- ca (#3): TestIssueCertificateFromCSR_IdentityComesFromServerNotCSR uses a CSR
  CN that DIFFERS from the server-supplied deviceID and asserts the issued
  cert's CN + Subject.SerialNumber (and VerifyCertificate/DeviceIDFromPEM) all
  come from the server id, never the attacker-controlled CSR CN — breaking the
  circular same-string sourcing the Success test had.

Refs #439 (WS17b #3/#13)

* test: stream cert/Hello mismatch (#5) + registration cert-CN binding & SAN rejection (#2)

- stream (#5): newStreamFixtureWithCert stamps an mTLS-cert device id into the
  server-side ctx; pin that a cert/Hello device-ID mismatch is CodePermissionDenied
  and a requireTLS connection with no cert identity is CodeUnauthenticated — both
  before VerifyDevice/StartWorker (recording control + fake worker stay empty).
- registration (#2): drive the real h.Register and assert the issued cert's CN is
  the SERVER-minted device id (not the attacker CSR CN) with no CSR SAN leaking
  in; a SAN-bearing CSR yields no certificate.

Refs #439 (WS17b #2/#5)

* test(api): self-discovering validate-before-work sweep for every ControlService RPC (#8)

Reflect over ControlServiceClient and drive a ZERO request through the real
client -> interceptor chain (logging -> auth -> validation -> authz) with an
admin bearer for every RPC; a non-exempt RPC MUST surface CodeInvalidArgument,
proving Validate() runs before the handler. RPCs whose zero request is
legitimately valid (list/read, current-user-scoped, partial-update, the
Validate* verdict RPCs) are exempted via validateExemptControlRPCs, guarded by a
companion test that every exemption names a live RPC. A new RPC is covered
automatically; matches-zero + matches-everything guards prevent vacuous passes.

Refs #439 (WS17b #8)

* test(api): Register pre-auth field bounds rejected by validation before token lookup (#9)

The absent/zero case is covered by the self-discovering sweep; this adds the
present-but-overlong boundary (Token>255, Hostname>253, AgentVersion>32, one over
each documented cap) driven through the real public Register RPC + validation
interceptor, asserting CodeInvalidArgument before the token lookup.

Refs #439 (WS17b #9)

* build: bump sdk pin to WS17b (#121) — request-field validate tags + verify hardening

Pulls in the new bound-able request-field validate tags (the validation
interceptor now enforces max=/ulid/url/email/dive bounds on the 33 newly-tagged
fields) and the ed25519/byte-tampered verify test hardening. Full server suite
passes — no existing request exceeded the conservative new bounds.

Refs #439

* docs(adr): 0022 — WS17b boundary hardening (cert-CN binding, search sanitizer, reconciler-owned seed, indexer mux)

Refs #439

* fix(compliance): recognize no-row via store.IsNotFound; evaluator coverage (#6)

Writing the grace-period/rollup/first_failed_at coverage the finding asked for
surfaced a real bug: the evaluator resolved 'no result yet -> UNKNOWN' and
'first-ever non-compliant -> seed first_failed_at' with errors.Is(err,
store.ErrNotFound). The generated queries return the backend's pgx.ErrNoRows — a
DIFFERENT sentinel — so those branches NEVER fired: any rule with no result yet,
or failing for the first time, ERRORED instead, aborting the whole device
evaluation (a rule just assigned, or a fresh failure, is the common case).
store/notfound.go mandates store.IsNotFound (matches both sentinels) for callers
outside the store package; the evaluator now uses it. (Sibling sweep: no other
caller had the pattern.)

Tests (RED against the broken check, GREEN after): grace IN_GRACE->NON_COMPLIANT
boundary on both sides via the clock seam; first_failed_at preserved across
re-eval; device rollup precedence (non-compliant > grace > compliant, UNKNOWN
keeps it out of COMPLIANT) + total/passing; no-policies recalculate-only fallback.

Refs #439 (WS17b #6)

* test(dyngroupeval): membership add/remove reconcile + short-circuits (#12)

The existing tests only exercised the queue lifecycle; these pin the actual
reconciliation EvaluateDeviceGroup/EvaluateUserGroup perform:
- a now-matching device/user is INSERTED and a stale member that no longer
  matches is DELETED, in a single evaluation (both directions);
- a non-dynamic group and a missing group each clear their queue row and write
  no membership (the two short-circuits);
- an unparseable dynamic_query returns an error, writes no membership, and leaves
  the queue row INTACT so a fixed query re-queues (fail-safe).

Refs #439 (WS17b #12)

* docs(adr): 0022 — record the compliance no-row recognition fix (#6)

* test(store): scope the reconciler-owned seed scan to individual statements (CR)

CodeRabbit: the whole-file scan could misclassify a migration that mentions a
role ID in one statement and sets unrelated permissions in another. Split on ';'
and evaluate each statement independently. Still RED-meaningful (drops 014 ->
flags 008's frozen literal).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: port project_luks_key_event projector to Go event listener

1 participant