28 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-data-layer-full-fidelity | 03 | execute | 3 |
|
|
true |
|
|
Purpose: this is the phase's other named security-load-bearing surface (mass assignment was Plan 02) — encryption key handling, redaction discipline, and the fact that Organisations land in the user plugin's migration set even though only Fonoteka's credential tables reference them this phase.
Output: lagoon.Encrypted, lagoon.KeyGenerateCommand, a Laravel-decrypt helper; golem15_user_organisations; ApiToken, OAuthClient, OAuthAuthCode, OAuthRefreshToken, UserAiCredential, OrgAiCredential, UserDiscogsCredential, OrgDiscogsCredential models and their 3 migrations.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md @.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md @.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.mdFrom summercms.go/lagoon/fill.go (Plan 05-01):
type HasHidden interface { Hidden() []string }
From fonoteka.go/plugins/golem15/fonoteka/models/registry.go and updates/registry.go, and the equivalent in plugins/golem15/user (Plan 05-01):
func Register(models ...any) // package models
func All() []any // package models
func Register(ms ...*gormigrate.Migration) // package updates
func All() []*gormigrate.Migration // package updates
From summercms.go/bouncer/jwt.go Verify (the fail-loud-on-empty-secret shape to copy for app.key):
func Verify(tokenString, secret string) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", fmt.Errorf("bouncer: jwt secret is empty")
}
...
}
From summercms.go/lagoon/commands.go RuntimeCommands (the command-trio shape key:generate joins):
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
return []bonfire.Command{ {Name: "migrate", ...}, {Name: "migrate:rollback", ...}, {Name: "migrate:status", ...} }
}
From summercms.go/compass/config.go (dot-path config access app.key/app.previous_keys will use):
func (c *Config) String(path string) string
func (c *Config) Lookup(path string) (any, bool)
In `lagoon/keygen.go`: implement `func KeyGenerateCommand() bonfire.Command` returning `bonfire.Command{Name: "key:generate", Description: "Print a fresh 32-byte base64 app.key", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { ... }}` using `crypto/rand.Read` on a 32-byte buffer, `out.Success(base64.StdEncoding.EncodeToString(key))` — never `math/rand`. Append `KeyGenerateCommand()` to the slice `RuntimeCommands` returns in `lagoon/commands.go` (one new line in the existing slice literal — a framework-owned file no other plan in this phase touches).
In `lagoon/laravel_decrypt.go`: implement `func DecryptLaravelPayload(payloadJSON string, appKey []byte) ([]byte, error)` — base64-JSON-decode `{iv, value, mac}` per Laravel's `encrypted` cast format, verify the HMAC-SHA256 `mac` over `iv+value` using `appKey` (raw bytes — Laravel uses the raw `APP_KEY` directly for both cipher and MAC key, verify against the RESEARCH.md-cited Laravel docs), then AES-256-CBC-decrypt `value` with `iv`, unpadding PKCS#7. Doc-comment it "cutover-import-only; never called from `Encrypted`'s live Scan/Value path" (D-10).
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestEncrypted
- All seven `Encrypted`/`key:generate`/`DecryptLaravelPayload` behaviors above pass as automated tests.
- `grep -rn "DecryptLaravelPayload("` shows zero call sites outside its own file and test.
- `grep -rn "golang.org/x/crypto" summercms.go/lagoon/encrypted.go summercms.go/go.mod` returns no matches (stdlib `crypto/hkdf` only, no new dependency).
- `go vet ./lagoon/...` is clean; `grep -rn "\"math/rand\"" summercms.go/lagoon/keygen.go` returns no matches.
`lagoon.Encrypted`, key derivation/rotation, `key:generate`, and the standalone Laravel decrypt helper exist, are fully tested, redaction is provably unbypassable via marshal/String/GoString, and HKDF derivation uses only the Go 1.27 standard library.
Task 2 (fonoteka.go): create_organisations (structure only) in the user plugin — no widen_users migration this phase
fonoteka.go/plugins/golem15/user/updates/10_organisations.go, fonoteka.go/plugins/golem15/user/models/organisation.go, fonoteka.go/config/app.yaml
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v3.2.0/create_organisations_table.php (confirm the exact filename via a directory listing if the version directory differs)
fonoteka.go/plugins/golem15/user/updates/00_base.go (moved by Plan 05-01 — the exact ID-naming and Migrate/Rollback shape to follow)
.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md (user_confirmed_decisions #2)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("widen_users finding", "Squashed Migration List" row 2, the anti-pattern note on organisations table ownership)
Create `updates/10_organisations.go` (package `updates`, in `plugins/golem15/user`) with a doc comment stating it folds `updates/v3.2.0/create_organisations_table.php` and explicitly noting: "Phase 5 ships no widen_users migration — deferred to Phase 7's AUTH-02 per 05-CONTEXT.md user-confirmed decision; this migration only creates the FK target `golem15_user_organisations` table that Fonoteka's org credential tables reference." Read the PHP migration file directly for its exact column list; at minimum expect `id SERIAL PRIMARY KEY`, `name TEXT NOT NULL`, `created_at`/`updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. Do not add `organisation_id`/`organisation_role` columns to `users`, and do not add membership/role tables even if the PHP version directory bundles more into the same migration — split them out, porting only the bare table this phase. `var organisationsMigrations = []*gormigrate.Migration{{ID: "_create_organisations", Migrate: ..., Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS golem15_user_organisations").Error }}}`, with `func init() { updates.Register(organisationsMigrations...) }`.
Create `models/organisation.go` (package `models`, in `plugins/golem15/user`) with `type Organisation struct { ID uint; Name string; CreatedAt time.Time; UpdatedAt time.Time }`, `func (Organisation) TableName() string { return "golem15_user_organisations" }`, `func init() { models.Register(Organisation{}) }` — structure only, no `Fillable`/`Rules`/relations beyond the FK target Fonoteka's credential models need (Phase 7 owns roles/membership behavior).
Add an `app.key: ""` entry to `fonoteka.go/config/app.yaml` as a documented placeholder — following `config/database.yaml`'s "Set SUMMER_..." comment-header convention: "# Set SUMMER_APP__KEY to a 32-byte base64 value (summer key:generate). Empty fails boot once any encrypted-cast model runs." Do not set a real value.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./...
- `golem15_user_organisations` is created by a testcontainers-backed migration test in the `user` plugin's own module, not fonoteka's.
- `grep -rn "organisation_id\|organisation_role" fonoteka.go/plugins/golem15/user/updates/10_organisations.go` returns no matches on the `users` table (only the new table's own columns exist).
- `grep -rln "widen_users" fonoteka.go/plugins/golem15/user/updates` returns no matches (no such migration exists).
`golem15_user_organisations` exists as a structure-only table owned by the user plugin's migration set; no widen_users migration ships this phase.
Task 3 (fonoteka.go): secrets-slice migrations and models — api_tokens, OAuth tables, credential tables, all with Hidden hash/secret columns and lagoon.Encrypted credential columns
fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go,
fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, models/oauth_client.go, models/oauth_auth_code.go, models/oauth_refresh_token.go,
fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go, models/org_ai_credential.go, models/user_discogs_credential.go, models/org_discogs_credential.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.3/create_fonoteka_api_tokens_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.6/add_area_ids_to_api_tokens.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/create_oauth_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/add_oauth_client_id_to_api_tokens.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.9/add_scope_ceiling_to_oauth_clients.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.4/create_user_ai_credentials_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.5/create_user_discogs_credentials_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.3/create_org_credentials_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/UserAiCredential.php, OrgAiCredential.php, UserDiscogsCredential.php, OrgDiscogsCredential.php (exact $casts/$hidden confirmation)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 4, 13, 14, 15, 16, 17, 21, 23; "Pattern: Encrypted-at-rest cast" code example)
Create `updates/11_secrets_slice.go` (package `updates`) with three migrations, IDs continuing Plan 05-02's `updates/10_album_slice.go` date/sequence (Wave 2 already shipped that file; this plan is Wave 3, no shared line edited):
1. `create_api_tokens` — `golem15_fonoteka_api_tokens (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT, token_hash TEXT NOT NULL UNIQUE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, last_used_ip TEXT, oauth_client_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify exact types against the two folded files; `scopes`/`collection_ids` are `text` jsonable columns per RESEARCH.md row 4, not native `jsonb`).
2. `create_oauth_tables` — `golem15_fonoteka_oauth_clients (id SERIAL PRIMARY KEY, client_id TEXT NOT NULL UNIQUE, client_secret_hash TEXT NOT NULL, client_name TEXT, redirect_uris TEXT, grant_types TEXT, token_endpoint_auth_method TEXT, registration_ip TEXT, consented_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, scope_ceiling TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_auth_codes (id SERIAL PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, code_hash TEXT NOT NULL UNIQUE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, redirect_uri TEXT, scopes TEXT, collection_ids TEXT, code_challenge TEXT, code_challenge_method TEXT, resource TEXT, state TEXT, expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_refresh_tokens (id SERIAL PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, api_token_id INTEGER REFERENCES golem15_fonoteka_api_tokens(id) ON DELETE CASCADE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, rotated_to_id INTEGER, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify against both folded files including `scope_ceiling`).
3. `create_credentials_tables` — `golem15_fonoteka_user_ai_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, base_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_ai_credentials` (same shape, `organisation_id INTEGER NOT NULL UNIQUE REFERENCES golem15_user_organisations(id) ON DELETE CASCADE` in place of `user_id`), `golem15_fonoteka_user_discogs_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_discogs_credentials` (same shape with `organisation_id`) — `api_key`/`token` are `text` columns (never `varchar`, confirmed live per RESEARCH.md). This migration depends on `create_organisations` (Task 2) for the FK target; since gormigrate runs each plugin's set independently in `party.Activate` dependency order and `golem15.fonoteka` `Requires()` `golem15.user`, this ordering is already guaranteed — do not add an explicit cross-plugin migration dependency mechanism.
End the file with `func init() { updates.Register(apiTokenMigration, oauthTablesMigration, credentialsTablesMigration) }` (or a single slice var, matching `00_base.go`'s style).
Create the 8 model files (package `models`), each following the exact struct/tag shape from RESEARCH.md's per-model inventory rows and the D-10..D-13 code example: `ApiToken` (row 4: `UserID`, `Name *string`, `TokenHash string` `json:"-"`, `Scopes lagoon.Jsonable[[]string]`, `CollectionIDs lagoon.Jsonable[[]uint]`, `ExpiresAt`/`RevokedAt`/`LastUsedAt *time.Time`, `LastUsedIP *string`, `OAuthClientID *string`; `Hidden()` returns `{"token_hash"}`); `OAuthClient` (row 14, `ClientSecretHash string` `json:"-"`, `Hidden()` `{"client_secret_hash"}`); `OAuthAuthCode` (row 13, `CodeHash string` `json:"-"`, `Hidden()` `{"code_hash"}`); `OAuthRefreshToken` (row 15, `TokenHash string` `json:"-"`, `Hidden()` `{"token_hash"}`); `UserAiCredential`/`OrgAiCredential` (rows 21/16, `APIKey lagoon.Encrypted` `gorm:"column:api_key"` `json:"-"`, `Hidden()` `{"api_key"}`, exactly matching the code example in RESEARCH.md's "Pattern: Encrypted-at-rest cast" section); `UserDiscogsCredential`/`OrgDiscogsCredential` (rows 23/17, `Token lagoon.Encrypted` `gorm:"column:token"` `json:"-"`, `Hidden()` `{"token"}`). Every file's `init()` calls `models.Register(TheType{})`.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run TestMigrateSeedsCanonicalGenres
- A testcontainers-backed migration test migrates `updates.All()` for `golem15.fonoteka` and asserts all 6 new tables exist (`golem15_fonoteka_api_tokens`, the 3 OAuth tables, the 4 credential tables — 8 total across the 3 migrations) and roll each back individually.
- `grep -rn "json:\"-\"" fonoteka.go/plugins/golem15/fonoteka/models/{api_token,oauth_client,oauth_auth_code,oauth_refresh_token,user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows every hash/secret/credential column tagged.
- `grep -rn "lagoon.Encrypted" fonoteka.go/plugins/golem15/fonoteka/models/{user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows exactly one match per file, on the `api_key`/`token` field.
3 migrations create the api_tokens/OAuth/credentials tables; 8 models exist with correct Hidden()/json:"-" and lagoon.Encrypted-typed secret columns, self-registered.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| app.key config -> Encrypted column key | a misconfigured or absent key must never silently fall back to a weak/default key |
| stored ciphertext -> old key holder | key rotation must not lock out legitimately-encrypted-under-the-old-key rows, nor allow decrypting with a key that was never a real primary |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-05-08 | Information Disclosure | lagoon.Encrypted accidental serialization |
mitigate | json:"-" backstop plus MarshalJSON/String/GoString all hardcoded to redact; Hidden() on every credential model names the same column (D-13) |
| T-05-09 | Information Disclosure / Denial of Service | key rotation | mitigate | Versioned ciphertext (key-id prefix) plus app.previous_keys decrypt-only fallback prevents both silent data loss and indefinite reuse of a retired key (D-12) |
| T-05-10 | Tampering | app.key missing/weak |
mitigate | LoadAppKey fails boot loudly on empty/short/undecodable input, no default value path exists (D-11) |
| T-05-11 | Tampering (supply chain) | HKDF key derivation for lagoon.Encrypted |
accept | 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 | Repudiation | api_tokens/oauth_refresh_tokens store only *_hash columns, never raw secrets |
accept | Matches PHP's own design; raw tokens are never persisted anywhere, hashed only, consistent with the Hidden() backstop |
| </threat_model> |
<success_criteria>
lagoon.Encryptedround-trips, redacts on every marshal path, supports key rotation viaapp.previous_keys, fails boot loudly on a badapp.key, and derives its column key using only the Go 1.27 standard library (no new dependency).key:generateand the standalone Laravel decrypt helper exist and are tested.golem15_user_organisationsexists, owned by the user plugin; nowiden_usersmigration ships this phase.- All 3 secrets-slice migrations and 8 models exist, self-register, and have correct
Hidden()/json:"-"/lagoon.Encryptedtyping. </success_criteria>