Files
summercms/.planning/phases/05-data-layer-full-fidelity/05-SECURITY-REVIEW.md
Jakub Zych e0a11c3ea0 docs(05-06): security review of fill and encryption boundaries
- Map T-05-01 through T-05-24 to a passing test or restated accept rationale
- Enumerate every .Reveal() call site; none are on log or serialize paths
- Confirm zero AutoMigrate production calls and no Association Mode pivot writes
2026-09-18 20:51:35 +02:00

9.4 KiB

Phase 5 Security Review — fill and encryption boundaries

Date: 2026-09-18 Scope: Plans 05-01 through 05-06. Mass-assignment (lagoon.Fill, write-service allow-lists) and encrypted-at-rest credentials (lagoon.Encrypted, .Reveal()). Repos grepped: summercms.go and fonoteka.go (excluding .planning/ and vendor/).

This is the phase-close review CONTEXT.md requires. Every T-05-* ID from the five prior plans (and this plan's own register) is mapped below to a passing test or a restated accept/transfer rationale. Unmapped IDs are a review gap, not an accepted risk.

Fill boundary

PHP's mass-assignment boundary is two-layered (D-05/D-06/D-07): model Fillable() is the backstop; write services pass a narrower list. json.Unmarshal into a GORM model is never used on a write path.

Layer Allow-list Proven by
Framework lagoon.Fill(model, allowed, requested, production) copies only allow-listed gorm column keys TestFillAllowList, FuzzFill (20s, 0 crashers)
Album AlbumFillFields excludes collection_id and market_price_source TestSaveAlbumFillFieldsSubset, FuzzSaveAlbum (20s, 0 crashers)
Collection CollectionFillFields is name+description (no owner_id/public_token/kind) FuzzSaveCollection (20s, 0 crashers)
Credentials Fillable() minus owner FK and Encrypted columns (no dedicated write-service file) FuzzSaveUserAiCredential, FuzzSaveOrgAiCredential, FuzzSaveUserDiscogsCredential, FuzzSaveOrgDiscogsCredential

Non-fillable and unknown keys are dropped with no error. In non-production they log once per type+key (TestFillDroppedKeyLogsOnce); production is silent (TestFillProductionSilent).

Encryption boundary

lagoon.Encrypted plaintext is unexported. The only greppable accessor is .Reveal(). MarshalJSON, String, and GoString always emit [redacted]. Live Scan/Value is AES-256-GCM only; DecryptLaravelPayload is cutover-import-only and is not on the Scan/Value path.

Boot fails loud on empty/short/undecodable app.key (TestEncryptedLoadAppKeyRejectsInvalid). Column keys are HKDF-SHA256 from app.key with info summercms.lagoon.encrypted.v1 (stdlib crypto/hkdf, no golang.org/x/crypto).

.Reveal() call sites

rg -n '\.Reveal\(\)' over both trees (excluding .planning/):

File Role Logging / Serialize* / marshal path?
summercms.go/lagoon/encrypted.go:42 method definition no
summercms.go/lagoon/encrypted_test.go (4 call sites) unit assertions for round-trip and rotation no — test only
fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service_fuzz_test.go (8 call sites) fuzz assertions that ciphertext round-trips no — test only

Zero call sites in classes/serialize.go, slog/log statements, String/GoString/MarshalJSON, or any production handler. Encrypted.String/GoString/MarshalJSON return the redaction literal and never call Reveal.

Hidden / json:"-"

TestHiddenNeverMarshals (lagoon fixture + both models.All() registries, 26 Fonoteka + 2 user = 28) populates every Hidden() column with a sentinel and asserts it is absent from json.Marshal output. Every Hidden() column is independently required to carry json:"-". T-05-03's 05-01 accept ("no real model implements them yet") is closed by this test.

Association Mode and AutoMigrate

rg -n '\.Association\(' finds only a comment in lagoon/relations.go documenting that pivot writes must not use Association Mode. syncArtists is delete-then-Create (TestAlbumArtistsOrderRoundTrip).

rg -n 'AutoMigrate' fonoteka.go/plugins summercms.go/lagoon matches only:

  • lagoon/migrations_test.go TestNoAutoMigrate (fails if production lagoon .go files contain the token)
  • parity/migrate_test.go TestPluginMigrationsDoNotUseAutoMigrate (walks fonoteka.go/plugins)

Zero production AutoMigrate calls. Zero-tolerance rule held across all 5 prior plans' new code.

T-05-17 allow-list audit

fonoteka.go/parity/schema_diff_test.go allowedDiffs has three entries, each with an inline decision comment:

Key Comment names
golem15_fonoteka_settings RESEARCH Open Question 2
users D-03 / Phase 7 AUTH-02, no widen_users
golem15_fonoteka_oauth_refresh_tokens.fk:...user_id... 05-03 extra FK, P3 D-17 freeze

The map is not being used to hide accidental missing columns: column presence, unique constraints, and non-allow-listed FKs still fail-hard (TestSchemaMatchesPHPSnapshot).

Threat register

Threat ID Plan Disposition Evidence
T-05-01 05-01 mitigate TestFillAllowList, TestFillDroppedKeyNeverErrors, FuzzFill — unknown/unlisted keys never set
T-05-02 05-01 mitigate TestParityCorpus (Phase 3 genres fixture through jwt.auth + inv.must-change-password); plugin restructure did not rename the middleware (plugin.go still registers inv.must-change-password)
T-05-03 05-01 mitigate (was accept until models existed) lagoon.TestHiddenNeverMarshals + classes.TestHiddenNeverMarshals over both models.All() (28 types). 05-01's accept rationale is superseded
T-05-04 05-02 mitigate TestSaveAlbumFillFieldsSubset, FuzzSaveAlbum — collection_id / market_price_source stay at pre-fuzz values
T-05-05 05-02 mitigate TestAlbumArtistsOrderRoundTrip — ordered pivot rewrite inside the SaveAlbum transaction
T-05-06 05-02 accept Restated: table/column names are sourced from the model's own compiled Rules() map, never from request input; the value is parameterized
T-05-07 05-02 accept + proven Restated: matches PHP's actual plain-unique constraint exactly (no partial index) per the D-02 audit. Proven by TestCollectionPublicTokenDeleteThenRecreate (soft-delete still unique-violates; hard-delete frees the token)
T-05-08 05-03 mitigate TestEncryptedRedacts, TestHiddenNeverMarshals, credential json:"-" + Hidden(); .Reveal() grep above
T-05-09 05-03 mitigate TestEncryptedPreviousKeysFallback — versioned ciphertext plus app.previous_keys decrypt-only
T-05-10 05-03 mitigate TestEncryptedLoadAppKeyRejectsInvalid — empty/short/undecodable app.key fails, no default
T-05-11 05-03 accept Restated: uses the Go 1.27 standard library crypto/hkdf package — no new dependency is introduced (this corrects an earlier draft of this plan that proposed golang.org/x/crypto/hkdf; Go 1.24+ ships HKDF in stdlib, so CLAUDE.md's stdlib-first rule applies directly and there is nothing for a package-legitimacy checkpoint to review)
T-05-12 05-03 accept Restated: matches PHP's own design; raw tokens are never persisted anywhere, hashed only, consistent with the Hidden() backstop. TestHiddenNeverMarshals covers token_hash / code_hash / client_secret_hash
T-05-13 05-04 mitigate TestStaticHandler — exact-match partition+disk_name only; .. / extra slashes 404
T-05-14 05-04 mitigate TestFileLifecycle, TestAttachSmoke — rows deleted in-tx, blobs only after commit via DeleteKeys
T-05-15 05-04 accept Restated: both [OK] in RESEARCH.md's Package Legitimacy Audit; no blocking human-verify checkpoint required
T-05-16 05-04 accept Restated: this phase's StaticHandler is not yet wired into any route (HTTP-route boundary of this phase); the is_public gate is Phase 6/12's job when the handler is actually mounted — flagged here so it is not forgotten at wiring time
T-05-17 05-05 mitigate allowedDiffs entries each have an inline decision comment (audit in this document); TestSchemaMatchesPHPSnapshot
T-05-18 05-05 accept Restated: deliberate parity match with PHP (RESEARCH.md Open Question 3); documented in the migration's own comment so it is never "fixed" by a later, uninformed change
T-05-19 05-05 accept Restated: test-only fixture plugin, not loaded by production app/app.go; gated behind models.Album type-assertion so it cannot fire for any other model; not wired into any HTTP path this phase. TestCrossPluginCallback
T-05-20 05-06 accept Restated: 20s minimum fuzz time per target plus explicit adversarial seed corpus entries (collection_id, market_price_source, organisation_id overrides) named directly in the task rather than left to chance discovery
T-05-21 05-06 mitigate TestHiddenNeverMarshals asserts models.All() counts (26+2) so a silently skipped model fails
T-05-22 05-06 mitigate This document maps T-05-01 through T-05-24 by name
T-05-23 05-02 mitigate TestSaveAlbumMoneyNormalization — blank/non-numeric → NULL, "12.55555" → "12.5556", no panic
T-05-24 05-02 mitigate TestCollectionBeforeDeleteCascadesAlbums, TestWithSoftDeleteCascadeRollback — cascade shares the parent delete transaction

Highest-numbered ID across the five prior plans is T-05-24 (05-02). T-05-20..22 belong to this closing plan. None left unmapped.

Residual / transferred risk

  • HTTP-level DTO fuzz over every write endpoint remains Phase 12 (D-07, second half of criterion 3).
  • StaticHandler is_public gate remains Phase 6/12 (T-05-16).
  • Laravel CBC decrypt is Phase 15 cutover only (D-10).
  • Organisation membership/roles and widen_users remain Phase 7 AUTH-02 (T-05-17 users gap).