Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/api/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func (h *AuthHandler) Login(ctx context.Context, req *connect.Request[pm.LoginRe
StreamType: "user",
StreamID: user.ID,
EventType: string(eventtypes.UserLoggedIn),
Data: map[string]any{},
Data: payloads.UserLoggedIn{},
ActorType: "user",
ActorID: user.ID,
}); err != nil {
Expand Down
8 changes: 4 additions & 4 deletions internal/api/idp_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ func (h *IDPHandler) DeleteIdentityProvider(ctx context.Context, req *connect.Re
StreamType: "identity_provider",
StreamID: providerID,
EventType: string(eventtypes.IdentityProviderDeleted),
Data: map[string]any{},
Data: payloads.IdentityProviderDeleted{},
ActorType: "user",
ActorID: userCtx.ID,
}, "failed to delete provider"); err != nil {
Expand All @@ -277,7 +277,7 @@ func (h *IDPHandler) DeleteIdentityProvider(ctx context.Context, req *connect.Re
StreamType: "identity_provider",
StreamID: link.ID,
EventType: string(eventtypes.IdentityUnlinked),
Data: map[string]any{},
Data: payloads.IdentityUnlinked{UserID: link.UserID, ProviderID: providerID},
ActorType: "user",
ActorID: userCtx.ID,
}); err != nil {
Expand Down Expand Up @@ -312,7 +312,7 @@ func (h *IDPHandler) DeleteIdentityProvider(ctx context.Context, req *connect.Re
StreamType: "user",
StreamID: link.UserID,
EventType: string(eventtypes.UserDeleted),
Data: map[string]any{},
Data: payloads.UserDeleted{},
ActorType: "user",
ActorID: userCtx.ID,
})
Expand Down Expand Up @@ -409,7 +409,7 @@ func (h *IDPHandler) DisableSCIM(ctx context.Context, req *connect.Request[pm.Di
StreamType: "identity_provider",
StreamID: req.Msg.Id,
EventType: string(eventtypes.IdentityProviderSCIMDisabled),
Data: map[string]any{},
Data: payloads.IdentityProviderSCIMDisabled{},
ActorType: "user",
ActorID: userCtx.ID,
}, "failed to disable SCIM"); err != nil {
Expand Down
12 changes: 6 additions & 6 deletions internal/api/totp_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func (h *TOTPHandler) VerifyTOTP(ctx context.Context, req *connect.Request[pm.Ve
StreamType: "totp",
StreamID: userCtx.ID,
EventType: string(eventtypes.TOTPVerified),
Data: map[string]any{},
Data: payloads.TOTPVerified{},
ActorType: "user",
ActorID: userCtx.ID,
}, "failed to verify TOTP"); err != nil {
Expand Down Expand Up @@ -210,7 +210,7 @@ func (h *TOTPHandler) DisableTOTP(ctx context.Context, req *connect.Request[pm.D
StreamType: "totp",
StreamID: userCtx.ID,
EventType: string(eventtypes.TOTPDisabled),
Data: map[string]any{},
Data: payloads.TOTPDisabled{},
ActorType: "user",
ActorID: userCtx.ID,
}, "failed to disable TOTP"); err != nil {
Expand Down Expand Up @@ -251,7 +251,7 @@ func (h *TOTPHandler) AdminDisableUserTOTP(ctx context.Context, req *connect.Req
StreamType: "totp",
StreamID: targetUserID,
EventType: string(eventtypes.TOTPDisabled),
Data: map[string]any{"admin": true},
Data: payloads.TOTPDisabled{Admin: true},
ActorType: "user",
ActorID: userCtx.ID,
}, "failed to disable TOTP"); err != nil {
Expand Down Expand Up @@ -421,7 +421,7 @@ func (h *TOTPHandler) VerifyLoginTOTP(ctx context.Context, req *connect.Request[
StreamType: "totp",
StreamID: claims.UserID,
EventType: string(eventtypes.TOTPBackupCodeUsed),
Data: map[string]any{"index": idx},
Data: payloads.TOTPBackupCodeUsed{Index: idx},
ActorType: "user",
ActorID: claims.UserID,
}, expectedVersion)
Expand Down Expand Up @@ -466,7 +466,7 @@ func (h *TOTPHandler) VerifyLoginTOTP(ctx context.Context, req *connect.Request[
StreamType: "totp",
StreamID: claims.UserID,
EventType: string(eventtypes.TOTPBackupCodeUsed),
Data: map[string]any{"index": retryIdx},
Data: payloads.TOTPBackupCodeUsed{Index: retryIdx},
ActorType: "user",
ActorID: claims.UserID,
}, retryExpectedVersion)
Expand Down Expand Up @@ -542,7 +542,7 @@ func (h *TOTPHandler) VerifyLoginTOTP(ctx context.Context, req *connect.Request[
StreamType: "user",
StreamID: claims.UserID,
EventType: string(eventtypes.UserLoggedIn),
Data: map[string]any{},
Data: payloads.UserLoggedIn{},
ActorType: "user",
ActorID: claims.UserID,
}); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/user_group_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func (h *UserGroupHandler) UpdateUserGroup(ctx context.Context, req *connect.Req
EventType: string(eventtypes.UserGroupUpdated),
Data: payloads.UserGroupUpdated{
Name: req.Msg.Name,
Description: req.Msg.Description,
Description: &req.Msg.Description,
},
ActorType: "user",
ActorID: userCtx.ID,
Expand Down
6 changes: 4 additions & 2 deletions internal/api/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,16 +417,18 @@ func (h *UserHandler) SetUserDisabled(ctx context.Context, req *connect.Request[

// Emit appropriate event
eventType := string(eventtypes.UserEnabled)
var data any = payloads.UserEnabled{}
if req.Msg.Disabled {
eventType = string(eventtypes.UserDisabled)
data = payloads.UserDisabled{}
}

appendDisable := func() error {
if err := h.store.AppendEvent(ctx, store.Event{
StreamType: "user",
StreamID: req.Msg.Id,
EventType: eventType,
Data: map[string]any{},
Data: data,
ActorType: "user",
ActorID: userCtx.ID,
}); err != nil {
Expand Down Expand Up @@ -489,7 +491,7 @@ func (h *UserHandler) DeleteUser(ctx context.Context, req *connect.Request[pm.De
StreamType: "user",
StreamID: req.Msg.Id,
EventType: string(eventtypes.UserDeleted),
Data: map[string]any{},
Data: payloads.UserDeleted{},
ActorType: "user",
ActorID: userCtx.ID,
}); err != nil {
Expand Down
132 changes: 132 additions & 0 deletions internal/archtest/typed_payloads_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package archtest

import (
"go/ast"
"sort"
"strings"
"testing"
)

// TestEventPayloadsAreTyped ratchets the F-05 conversion (#507): event
// emit sites must pass a typed struct from internal/eventtypes/payloads
// as Data, not a map[string]any literal. One shared struct between the
// emit site and the projector decoder catches wire-schema drift at
// compile time; a map literal re-opens the renamed-key / typo'd-key /
// dropped-field class the payloads package exists to close. Spec 19's
// pii:"true" reflection also only works over typed structs — an
// untyped PII emit site is invisible to the crypto layer.
//
// Mechanics: any composite literal carrying BOTH an EventType: and a
// Data: key (store.Event, idp.EventInput — every event-append shape in
// the module) whose Data value is a map composite literal is a legacy
// site. The allowlist below holds the not-yet-converted remainder with
// EXACT per-file counts, so the number can only shrink: a NEW map
// emit anywhere fails, a count above the allowance fails, and a count
// below (someone converted a site) fails too until the entry here is
// shrunk in the same commit — keeping the ratchet honest.
//
// internal/testutil is excluded: factories seed legacy wire shapes on
// purpose and are not production emit sites (drift there fails tests,
// not projections). Assigning a map VARIABLE to Data evades the
// syntactic detector — accepted; review catches it, and the projector
// decode contract still holds.
func TestEventPayloadsAreTyped(t *testing.T) {
root := moduleRoot(t)
files := walkGoFiles(t, root, func(rel string) bool {
if strings.HasPrefix(rel, "internal/store/generated/") {
return false
}
if strings.HasPrefix(rel, "internal/testutil/") {
return false
}
return true
})
if len(files) == 0 {
t.Fatal("matches-zero guard: walked zero production Go files — detector is mis-scoped")
}

// The F-05 remainder (issue #507, "broader" checkbox): non-PII emit
// sites pending package-by-package conversion. Exact counts.
allowed := map[string]int{
"internal/api/action_crud.go": 2,
"internal/api/action_dispatch.go": 1,
"internal/api/action_set_handler.go": 1,
"internal/api/compliance_policy_handler.go": 1,
"internal/api/definition_handler.go": 1,
"internal/api/device_group_handler.go": 1,
"internal/api/device_handler.go": 1,
"internal/api/role_handler.go": 2,
"internal/api/settings_handler.go": 1,
"internal/api/system_action_store.go": 1,
"internal/api/token_handler.go": 2,
"internal/api/user_group_handler.go": 2,
}

sawEmitLiteral := 0 // liveness: every EventType+Data literal, typed or not
found := map[string][]int{}
for _, gf := range files {
ast.Inspect(gf.ast, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
dataVal, isEmit := eventDataValue(lit)
if !isEmit {
return true
}
sawEmitLiteral++
inner, ok := dataVal.(*ast.CompositeLit)
if !ok {
return true
}
if _, isMap := inner.Type.(*ast.MapType); isMap {
found[gf.rel] = append(found[gf.rel], gf.line(inner))
}
return true
})
}
if sawEmitLiteral == 0 {
t.Fatal("matches-zero guard: found no event-append composite literal anywhere — the detector is dead, the guard would pass vacuously")
}

for rel, lines := range found {
sort.Ints(lines)
switch want := allowed[rel]; {
case want == 0:
t.Errorf("untyped event payload: %s lines %v pass a map[string]any literal as Data — use a typed struct from internal/eventtypes/payloads (shared with the projector decoder) instead", rel, lines)
case len(lines) > want:
t.Errorf("untyped event payloads grew in %s: %d map-literal Data sites (lines %v), allowance is %d — convert the new site to a typed payloads struct", rel, len(lines), lines, want)
case len(lines) < want:
t.Errorf("ratchet stale for %s: %d map-literal Data sites remain (lines %v) but the allowance is %d — shrink the entry in this test to %d", rel, len(lines), lines, want, len(lines))
}
}
for rel, want := range allowed {
if len(found[rel]) == 0 {
t.Errorf("ratchet stale: %s is allowed %d map-literal Data sites but has none — delete the entry", rel, want)
}
}
}

// eventDataValue reports whether lit is an event-append composite
// literal (it names both EventType: and Data: keys) and returns the
// Data value expression.
func eventDataValue(lit *ast.CompositeLit) (ast.Expr, bool) {
var dataVal ast.Expr
hasEventType := false
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
switch identName(kv.Key) {
case "EventType":
hasEventType = true
case "Data":
dataVal = kv.Value
}
}
if !hasEventType || dataVal == nil {
return nil, false
}
return dataVal, true
}
17 changes: 10 additions & 7 deletions internal/eventtypes/payloads/identity_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ type IdentityLinked struct {
ExternalName string `json:"external_name,omitempty"`
}

// IdentityLinkLoginUpdated is the wire shape for the SSO refresh path
// — the user already has a link, but the IdP's claims drifted (display
// name, email). The projector updates the existing row's denormalised
// external_email / external_name. user_id is intentionally omitted
// here (the link is keyed by the (provider_id, external_id) tuple
// alone — the user_id is loaded by the projector from the existing
// row).
// IdentityLinkLoginUpdated is the wire shape for the SSO/SCIM refresh
// path — the user already has a link, but the IdP's claims drifted
// (display name, email). The projector updates the existing row's
// denormalised external_email / external_name, keyed by the
// (provider_id, external_id) tuple. user_id names the owning user:
// the projector doesn't need it (the tuple keys the row), but spec 19
// does — external_email/external_name are PII, and the crypto-shred
// layer resolves the DEK owner from this field (#507). Events emitted
// before the field existed decode with UserID == "".
type IdentityLinkLoginUpdated struct {
UserID string `json:"user_id"`
ProviderID string `json:"provider_id"`
ExternalID string `json:"external_id"`
ExternalEmail string `json:"external_email,omitempty"`
Expand Down
11 changes: 11 additions & 0 deletions internal/eventtypes/payloads/idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ type IdentityProviderSCIMTokenRotated struct {
ScimTokenHash string `json:"scim_token_hash"`
}

// IdentityProviderDeleted is the wire shape for IdentityProviderDeleted.
// Deliberately empty — the projector soft-deletes off stream_id and
// cascades the scim_group_mapping DELETE; `{}` on the wire matches the
// legacy map payload.
type IdentityProviderDeleted struct{}

// IdentityProviderSCIMDisabled is the wire shape for
// IdentityProviderSCIMDisabled. Deliberately empty — the projector
// clears the token hash + cascades off stream_id alone.
type IdentityProviderSCIMDisabled struct{}

// IdentityProviderCreated is the wire shape for the IdentityProviderCreated
// event. ClientSecretEncrypted is the AES-GCM ciphertext (the raw
// secret never enters the event store; the audit-log redactor also
Expand Down
Loading