db: integrate golang-migrate for schema migrations

- internal/db/migrations/: five embedded SQL files containing
  the migration SQL previously held as Go string literals.
  Files follow the NNN_description.up.sql naming convention
  required by golang-migrate's iofs source.
- internal/db/migrate.go: rewritten to use
  github.com/golang-migrate/migrate/v4 with the
  database/sqlite driver (modernc.org/sqlite, pure Go) and
  source/iofs for compile-time embedded SQL.
  - newMigrate() opens a dedicated *sql.DB so m.Close() does
    not affect the caller's shared connection.
  - Migrate() includes a compatibility shim: reads the legacy
    schema_version table and calls m.Force(v) before m.Up()
    so existing databases are not re-migrated.
  - LatestSchemaVersion promoted from var to const.
- internal/db/db.go: added path field to DB struct; Open()
  translates ':memory:' to a named shared-cache URI
  (file:mcias_N?mode=memory&cache=shared) so the migration
  runner can open a second connection to the same in-memory
  database without sharing the handle that golang-migrate
  will close on teardown.
- go.mod: added golang-migrate/migrate/v4 v4.19.1 (direct).
All callers unchanged. All tests pass; golangci-lint clean.
This commit is contained in:
2026-03-12 11:52:39 -07:00
parent b2f2f04646
commit d7b69ed983
12 changed files with 399 additions and 264 deletions

View File

@@ -4,6 +4,52 @@ Source of truth for current development state.
--- ---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean. All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-12 — Integrate golang-migrate for database migrations
**internal/db/migrations/** (new directory — 5 embedded SQL files)
- `000001_initial_schema.up.sql` — full initial schema (verbatim from migration 1)
- `000002_master_key_salt.up.sql` — adds `master_key_salt` to server_config
- `000003_failed_logins.up.sql``failed_logins` table for brute-force lockout
- `000004_tags_and_policy.up.sql``account_tags` and `policy_rules` tables
- `000005_pgcred_access.up.sql``owner_id` column + `pg_credential_access` table
- Files are embedded at compile time via `//go:embed migrations/*.sql`; no
runtime filesystem access is needed
**internal/db/migrate.go** (rewritten)
- Removed hand-rolled `migration` struct and `migrations []migration` slice
- Uses `github.com/golang-migrate/migrate/v4` with the `database/sqlite`
driver (modernc.org/sqlite, pure Go, no CGO) and `source/iofs` for embedded
SQL files
- `LatestSchemaVersion` changed from `var` to `const = 5`
- `Migrate(db *DB) error`: compatibility shim reads legacy `schema_version`
table; if version > 0, calls `m.Force(legacyVersion)` before `m.Up()` so
existing databases are not re-migrated. Returns nil on ErrNoChange.
- `SchemaVersion(db *DB) (int, error)`: delegates to `m.Version()`; returns 0
on ErrNilVersion
- `newMigrate(*DB)`: opens a **dedicated** `*sql.DB` for the migrator so that
`m.Close()` (which closes the underlying connection) does not affect the
caller's shared connection
- `legacySchemaVersion(*DB)`: reads old schema_version table; returns 0 if
absent (fresh DB or already on golang-migrate only)
**internal/db/db.go**
- Added `path string` field to `DB` struct for the migrator's dedicated
connection
- `Open(":memory:")` now translates to a named shared-cache URI
`file:mcias_N?mode=memory&cache=shared` (N is atomic counter) so the
migration runner can open a second connection to the same in-memory database
without sharing the `*sql.DB` handle that golang-migrate will close
**go.mod / go.sum**
- Added `github.com/golang-migrate/migrate/v4 v4.19.1` (direct)
- Transitive: `hashicorp/errwrap`, `hashicorp/go-multierror`,
`go.uber.org/atomic`
All callers (`cmd/mciassrv`, `cmd/mciasdb`, all test helpers) continue to call
`db.Open(path)` and `db.Migrate(database)` unchanged.
All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
### 2026-03-12 — UI: pgcreds create button; show logged-in user ### 2026-03-12 — UI: pgcreds create button; show logged-in user
**web/templates/pgcreds.html** **web/templates/pgcreds.html**

15
go.mod
View File

@@ -4,10 +4,13 @@ go 1.26.0
require ( require (
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/crypto v0.33.0 golang.org/x/crypto v0.45.0
golang.org/x/term v0.29.0 golang.org/x/term v0.37.0
google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.7
modernc.org/sqlite v1.46.1 modernc.org/sqlite v1.46.1
) )
@@ -17,12 +20,10 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/grpc v1.68.0 // indirect
google.golang.org/protobuf v1.36.0 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

64
go.sum
View File

@@ -1,46 +1,78 @@
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=

View File

@@ -12,19 +12,36 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"sync/atomic"
"time" "time"
_ "modernc.org/sqlite" // register the sqlite3 driver _ "modernc.org/sqlite" // register the sqlite3 driver
) )
// memCounter generates unique names for in-memory shared-cache databases.
var memCounter atomic.Int64
// DB wraps a *sql.DB with MCIAS-specific helpers. // DB wraps a *sql.DB with MCIAS-specific helpers.
type DB struct { type DB struct {
sql *sql.DB sql *sql.DB
// path is the DSN used to open this database. For in-memory databases
// (originally ":memory:") it is a unique shared-cache URI of the form
// file:mcias_N?mode=memory&cache=shared so that a second connection can be
// opened to the same in-memory database (needed by the migration runner).
path string
} }
// Open opens (or creates) the SQLite database at path and configures it for // Open opens (or creates) the SQLite database at path and configures it for
// MCIAS use (WAL mode, foreign keys, busy timeout). // MCIAS use (WAL mode, foreign keys, busy timeout).
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
// Translate bare ":memory:" to a named shared-cache in-memory URI.
// This allows the migration runner to open a second connection to the
// same in-memory database without sharing the *sql.DB handle (which
// would be closed by golang-migrate when the migrator is done).
if path == ":memory:" {
path = fmt.Sprintf("file:mcias_%d?mode=memory&cache=shared", memCounter.Add(1))
}
// The modernc.org/sqlite driver is registered as "sqlite". // The modernc.org/sqlite driver is registered as "sqlite".
sqlDB, err := sql.Open("sqlite", path) sqlDB, err := sql.Open("sqlite", path)
if err != nil { if err != nil {
@@ -34,7 +51,7 @@ func Open(path string) (*DB, error) {
// Use a single connection for writes; reads can use the pool. // Use a single connection for writes; reads can use the pool.
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)
db := &DB{sql: sqlDB} db := &DB{sql: sqlDB, path: path}
if err := db.configure(); err != nil { if err := db.configure(); err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
return nil, err return nil, err

View File

@@ -2,268 +2,141 @@ package db
import ( import (
"database/sql" "database/sql"
"embed"
"errors"
"fmt" "fmt"
"github.com/golang-migrate/migrate/v4"
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite" // driver registration
) )
// migration represents a single schema migration with an ID and SQL statement. // migrationsFS embeds all migration SQL files from the migrations/ directory.
type migration struct { // Each file is named NNN_description.up.sql (and optionally .down.sql).
sql string //
id int //go:embed migrations/*.sql
} var migrationsFS embed.FS
// migrations is the ordered list of schema migrations applied to the database. // LatestSchemaVersion is the highest migration version defined in the
// Once applied, migrations must never be modified — only new ones appended. // migrations/ directory. Update this constant whenever a new migration file
var migrations = []migration{ // is added.
{ const LatestSchemaVersion = 5
id: 1,
sql: `
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_config ( // newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
id INTEGER PRIMARY KEY CHECK (id = 1), // files. It opens a dedicated *sql.DB using the same DSN as the main
signing_key_enc BLOB, // database so that calling m.Close() (which closes the underlying connection)
signing_key_nonce BLOB, // does not affect the caller's main database connection.
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), //
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) // Security: migration SQL is embedded at compile time from the migrations/
); // directory and is never loaded from the filesystem at runtime, preventing
// injection of arbitrary SQL via a compromised working directory.
func newMigrate(database *DB) (*migrate.Migrate, error) {
src, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return nil, fmt.Errorf("db: create migration source: %w", err)
}
CREATE TABLE IF NOT EXISTS accounts ( // Open a dedicated connection for the migrator. golang-migrate's sqlite
id INTEGER PRIMARY KEY, // driver calls db.Close() when the migrator is closed; using a dedicated
uuid TEXT NOT NULL UNIQUE, // connection (same DSN, different *sql.DB) prevents it from closing the
username TEXT NOT NULL UNIQUE COLLATE NOCASE, // shared connection. For in-memory databases, Open() translates
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')), // ":memory:" to a named shared-cache URI so both connections see the same
password_hash TEXT, // data.
status TEXT NOT NULL DEFAULT 'active' migrateDB, err := sql.Open("sqlite", database.path)
CHECK (status IN ('active','inactive','deleted')), if err != nil {
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)), return nil, fmt.Errorf("db: open migration connection: %w", err)
totp_secret_enc BLOB, }
totp_secret_nonce BLOB, migrateDB.SetMaxOpenConns(1)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), if _, err := migrateDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), _ = migrateDB.Close()
deleted_at TEXT return nil, fmt.Errorf("db: migration connection pragma: %w", err)
); }
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username); driver, err := sqlitedriver.WithInstance(migrateDB, &sqlitedriver.Config{
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid); MigrationsTable: "schema_migrations",
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status); })
if err != nil {
_ = migrateDB.Close()
return nil, fmt.Errorf("db: create migration driver: %w", err)
}
CREATE TABLE IF NOT EXISTS account_roles ( m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
id INTEGER PRIMARY KEY, if err != nil {
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, return nil, fmt.Errorf("db: initialise migrator: %w", err)
role TEXT NOT NULL, }
granted_by INTEGER REFERENCES accounts(id), return m, nil
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, role)
);
CREATE INDEX IF NOT EXISTS idx_account_roles_account ON account_roles (account_id);
CREATE TABLE IF NOT EXISTS token_revocation (
id INTEGER PRIMARY KEY,
jti TEXT NOT NULL UNIQUE,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
revoke_reason TEXT,
issued_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_token_jti ON token_revocation (jti);
CREATE INDEX IF NOT EXISTS idx_token_account ON token_revocation (account_id);
CREATE INDEX IF NOT EXISTS idx_token_expires ON token_revocation (expires_at);
CREATE TABLE IF NOT EXISTS system_tokens (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
jti TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS pg_credentials (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
pg_host TEXT NOT NULL,
pg_port INTEGER NOT NULL DEFAULT 5432,
pg_database TEXT NOT NULL,
pg_username TEXT NOT NULL,
pg_password_enc BLOB NOT NULL,
pg_password_nonce BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY,
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
event_type TEXT NOT NULL,
actor_id INTEGER REFERENCES accounts(id),
target_id INTEGER REFERENCES accounts(id),
ip_address TEXT,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
`,
},
{
id: 2,
sql: `
-- Add master_key_salt to server_config for Argon2id KDF salt storage.
-- The salt must be stable across restarts so the passphrase always yields the same key.
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
`,
},
{
id: 3,
sql: `
-- Track per-account failed login attempts for lockout enforcement (F-08).
-- One row per account; window_start resets when the window expires or on
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
CREATE TABLE IF NOT EXISTS failed_logins (
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1
);
`,
},
{
id: 4,
sql: `
-- Machine/service tags on accounts (many-to-many).
-- Used by the policy engine to gate access by machine or service identity
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
CREATE TABLE IF NOT EXISTS account_tags (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (account_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
-- Policy rules stored in the database and evaluated in-process.
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
-- Built-in default rules are compiled into the binary and are not stored here.
-- Rows with enabled=0 are loaded but skipped during evaluation.
CREATE TABLE IF NOT EXISTS policy_rules (
id INTEGER PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL,
rule_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`,
},
{
id: 5,
sql: `
-- Track which accounts own each set of pg_credentials and which other
-- accounts have been granted read access to them.
--
-- owner_id: the account that administers the credentials and may grant/revoke
-- access. Defaults to the system account itself. This column is
-- nullable so that rows created before migration 5 are not broken.
ALTER TABLE pg_credentials ADD COLUMN owner_id INTEGER REFERENCES accounts(id);
-- pg_credential_access records an explicit "all-or-nothing" read grant from
-- the credential owner to another account. Grantees may view connection
-- metadata (host, port, database, username) but the password is never
-- decrypted for them in the UI. Only the owner may update or delete the
-- credential set.
CREATE TABLE IF NOT EXISTS pg_credential_access (
id INTEGER PRIMARY KEY,
credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (credential_id, grantee_id)
);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_cred ON pg_credential_access (credential_id);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_grantee ON pg_credential_access (grantee_id);
`,
},
}
// LatestSchemaVersion is the highest migration ID in the migrations list.
// It is updated automatically when new migrations are appended.
var LatestSchemaVersion = migrations[len(migrations)-1].id
// SchemaVersion returns the current applied schema version of the database.
// Returns 0 if no migrations have been applied yet.
func SchemaVersion(database *DB) (int, error) {
return currentSchemaVersion(database.sql)
} }
// Migrate applies any unapplied schema migrations to the database in order. // Migrate applies any unapplied schema migrations to the database in order.
// It is idempotent: running it multiple times is safe. // It is idempotent: running it on an already-current database is safe and
func Migrate(db *DB) error { // returns nil.
// Ensure the schema_version table exists first. //
if _, err := db.sql.Exec(` // Existing databases that were migrated by the previous hand-rolled runner
CREATE TABLE IF NOT EXISTS schema_version ( // (schema_version table) are handled by the compatibility shim below: the
version INTEGER NOT NULL // legacy version is read and used to fast-forward the golang-migrate state
) // before calling Up, so no migration is applied twice.
`); err != nil { func Migrate(database *DB) error {
return fmt.Errorf("db: ensure schema_version: %w", err) // Compatibility shim: if the database was previously migrated by the
} // hand-rolled runner it has a schema_version table with the current
// version. Inform golang-migrate of the existing version so it does
currentVersion, err := currentSchemaVersion(db.sql) // not try to re-apply already-applied migrations.
legacyVersion, err := legacySchemaVersion(database)
if err != nil { if err != nil {
return fmt.Errorf("db: get current schema version: %w", err) return fmt.Errorf("db: read legacy schema version: %w", err)
} }
for _, m := range migrations { m, err := newMigrate(database)
if m.id <= currentVersion { if err != nil {
continue return err
} }
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
tx, err := db.sql.Begin() if legacyVersion > 0 {
if err != nil { // Force the migrator to treat the database as already at
return fmt.Errorf("db: begin migration %d transaction: %w", m.id, err) // legacyVersion so Up only applies newer migrations.
if err := m.Force(legacyVersion); err != nil {
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
} }
}
if _, err := tx.Exec(m.sql); err != nil { if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
_ = tx.Rollback() return fmt.Errorf("db: apply migrations: %w", err)
return fmt.Errorf("db: apply migration %d: %w", m.id, err)
}
// Update the schema version within the same transaction.
if currentVersion == 0 {
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.id); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: insert schema version %d: %w", m.id, err)
}
} else {
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, m.id); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: update schema version to %d: %w", m.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: commit migration %d: %w", m.id, err)
}
currentVersion = m.id
} }
return nil return nil
} }
// currentSchemaVersion returns the current schema version, or 0 if none applied. // SchemaVersion returns the current applied schema version of the database.
func currentSchemaVersion(db *sql.DB) (int, error) { // Returns 0 if no migrations have been applied yet.
var version int func SchemaVersion(database *DB) (int, error) {
err := db.QueryRow(`SELECT version FROM schema_version LIMIT 1`).Scan(&version) m, err := newMigrate(database)
if err != nil { if err != nil {
// No rows means version 0 (fresh database). return 0, err
}
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
v, _, err := m.Version()
if errors.Is(err, migrate.ErrNilVersion) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("db: read schema version: %w", err)
}
// Security: v is a migration version number (small positive integer);
// the uint→int conversion is safe for any realistic schema version count.
return int(v), nil //nolint:gosec // G115: migration version is always a small positive integer
}
// legacySchemaVersion reads the version from the old schema_version table
// created by the hand-rolled migration runner. Returns 0 if the table does
// not exist (fresh database or already migrated to golang-migrate only).
func legacySchemaVersion(database *DB) (int, error) {
var version int
err := database.sql.QueryRow(
`SELECT version FROM schema_version LIMIT 1`,
).Scan(&version)
if err != nil {
// Table does not exist or is empty — treat as version 0.
return 0, nil //nolint:nilerr return 0, nil //nolint:nilerr
} }
return version, nil return version, nil

View File

@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
signing_key_enc BLOB,
signing_key_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')),
password_hash TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive','deleted')),
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
totp_secret_enc BLOB,
totp_secret_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username);
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status);
CREATE TABLE IF NOT EXISTS account_roles (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
role TEXT NOT NULL,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, role)
);
CREATE INDEX IF NOT EXISTS idx_account_roles_account ON account_roles (account_id);
CREATE TABLE IF NOT EXISTS token_revocation (
id INTEGER PRIMARY KEY,
jti TEXT NOT NULL UNIQUE,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
revoke_reason TEXT,
issued_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_token_jti ON token_revocation (jti);
CREATE INDEX IF NOT EXISTS idx_token_account ON token_revocation (account_id);
CREATE INDEX IF NOT EXISTS idx_token_expires ON token_revocation (expires_at);
CREATE TABLE IF NOT EXISTS system_tokens (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
jti TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS pg_credentials (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
pg_host TEXT NOT NULL,
pg_port INTEGER NOT NULL DEFAULT 5432,
pg_database TEXT NOT NULL,
pg_username TEXT NOT NULL,
pg_password_enc BLOB NOT NULL,
pg_password_nonce BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY,
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
event_type TEXT NOT NULL,
actor_id INTEGER REFERENCES accounts(id),
target_id INTEGER REFERENCES accounts(id),
ip_address TEXT,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);

View File

@@ -0,0 +1,4 @@
-- Add master_key_salt to server_config for Argon2id KDF salt storage.
-- The salt must be stable across restarts so the passphrase always yields the same key.
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;

View File

@@ -0,0 +1,8 @@
-- Track per-account failed login attempts for lockout enforcement (F-08).
-- One row per account; window_start resets when the window expires or on
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
CREATE TABLE IF NOT EXISTS failed_logins (
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1
);

View File

@@ -0,0 +1,26 @@
-- Machine/service tags on accounts (many-to-many).
-- Used by the policy engine to gate access by machine or service identity
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
CREATE TABLE IF NOT EXISTS account_tags (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (account_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
-- Policy rules stored in the database and evaluated in-process.
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
-- Built-in default rules are compiled into the binary and are not stored here.
-- Rows with enabled=0 are loaded but skipped during evaluation.
CREATE TABLE IF NOT EXISTS policy_rules (
id INTEGER PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL,
rule_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

View File

@@ -0,0 +1,24 @@
-- Track which accounts own each set of pg_credentials and which other
-- accounts have been granted read access to them.
--
-- owner_id: the account that administers the credentials and may grant/revoke
-- access. Defaults to the system account itself. This column is
-- nullable so that rows created before migration 5 are not broken.
ALTER TABLE pg_credentials ADD COLUMN owner_id INTEGER REFERENCES accounts(id);
-- pg_credential_access records an explicit "all-or-nothing" read grant from
-- the credential owner to another account. Grantees may view connection
-- metadata (host, port, database, username) but the password is never
-- decrypted for them in the UI. Only the owner may update or delete the
-- credential set.
CREATE TABLE IF NOT EXISTS pg_credential_access (
id INTEGER PRIMARY KEY,
credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (credential_id, grantee_id)
);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_cred ON pg_credential_access (credential_id);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_grantee ON pg_credential_access (grantee_id);

View File

@@ -9,6 +9,17 @@ nav { background: #1a1a2e; color: #fff; padding: 0.5rem 1rem; }
.nav-links a { color: #ccc; text-decoration: none; } .nav-links a { color: #ccc; text-decoration: none; }
.nav-links a:hover { color: #fff; } .nav-links a:hover { color: #fff; }
.nav-actor { color: #aaa; font-size: 0.85rem; } .nav-actor { color: #aaa; font-size: 0.85rem; }
/* Login page layout */
.login-wrapper { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 2rem; }
.login-box { width: 100%; max-width: 380px; }
.brand-heading { font-size: 2rem; font-weight: 700; text-align: center; letter-spacing: 0.05em; color: #1a1a2e; margin-bottom: 0.25rem; }
.brand-subtitle { font-size: 0.8rem; text-align: center; color: #666; margin-bottom: 1.5rem; letter-spacing: 0.02em; }
.login-box .card { background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1.75rem 2rem; box-shadow: 0 2px 8px rgba(0,0,0,0.07); }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.35rem; font-size: 0.9rem; font-weight: 500; }
.form-control { width: 100%; padding: 0.5rem 0.6rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.95rem; }
.form-control:focus { outline: none; border-color: #0d6efd; box-shadow: 0 0 0 2px rgba(13,110,253,0.2); }
.form-actions { margin-top: 1.25rem; }
.btn { display: inline-block; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem; } .btn { display: inline-block; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; } .btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
.btn-primary { background: #0d6efd; color: #fff; } .btn-primary { background: #0d6efd; color: #fff; }

View File

@@ -10,6 +10,7 @@
<div class="login-wrapper"> <div class="login-wrapper">
<div class="login-box"> <div class="login-box">
<div class="brand-heading">MCIAS</div> <div class="brand-heading">MCIAS</div>
<div class="brand-subtitle">Metacircular Identity &amp; Access System</div>
<div class="card"> <div class="card">
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}} {{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
<form id="login-form" method="POST" action="/login" <form id="login-form" method="POST" action="/login"