Skip to content

Commit 9790337

Browse files
authored
feat: add least-privilege SQL runtime role (#924)
1 parent e11ebbe commit 9790337

36 files changed

Lines changed: 3162 additions & 138 deletions

.devcontainer/.env.example

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,30 @@ MSSQL_SA_PASSWORD=YourStrong!Passw0rd
1212
SQLSERVER_HOST_PORT=1433
1313

1414
DATABASE_READONLY_PASSWORD_ENV=DB_READONLY_PASSWORD
15+
DB_BOOTSTRAP_ADMIN_PASSWORD=YourStrong!Passw0rd
16+
DB_BOOTSTRAP_ADMIN_USER=sa
17+
DB_BOOTSTRAP_APP_PASSWORD=RuntimeOnly!Passw0rd7
18+
DB_BOOTSTRAP_APP_USER=kravhantering_app
1519
DB_ENCRYPT=true
1620
DB_HOST=db
21+
DB_MIGRATION_PASSWORD=MigrationOnly!Passw0rd7
22+
DB_MIGRATION_USER=kravhantering_job
1723
DB_NAME=kravhantering
24+
DB_PASSWORD=RuntimeOnly!Passw0rd7
1825
DB_PORT=1433
1926
DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7
2027
DB_READONLY_USER=readonly
28+
DB_RUNTIME_USER=kravhantering_app
2129
DB_TRUST_SERVER_CERTIFICATE=true
30+
DB_USER=kravhantering_app
2231
DOTNET_CLI_TELEMETRY_OPTOUT=1
2332
DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE=true
2433
DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK=true
2534
NEXT_TELEMETRY_DISABLED=1
2635
NODE_ENV=development
2736

2837
# Optional explicit overrides for the derived DB_* connection settings above:
29-
# DATABASE_URL=mssql://sa:YourStrong!Passw0rd@db:1433/kravhantering?encrypt=true&trustServerCertificate=true
38+
# DATABASE_URL=mssql://kravhantering_app:RuntimeOnly!Passw0rd7@db:1433/kravhantering?encrypt=true&trustServerCertificate=true
3039
# DATABASE_READONLY_URL=mssql://readonly:BrowseOnly!Passw0rd7@db:1433/kravhantering?encrypt=true&trustServerCertificate=true
3140

3241
# Local OIDC IdP (Keycloak) — consumed by the `idp` service in the

.env.development

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,26 @@
66
# below are intentionally well-known, non-production placeholders that match
77
# the committed Keycloak realm JSON in dev/keycloak/.
88

9+
DB_BOOTSTRAP_ADMIN_PASSWORD=YourStrong!Passw0rd
10+
DB_BOOTSTRAP_ADMIN_USER=sa
11+
DB_BOOTSTRAP_APP_PASSWORD=RuntimeOnly!Passw0rd7
12+
DB_BOOTSTRAP_APP_USER=kravhantering_app
913
DB_ENCRYPT=true
1014
# Local SQL Server defaults for host-based development.
1115
# The devcontainer injects its own DB_* values from `.devcontainer/.env`,
1216
# and those existing shell variables take precedence over these committed
1317
# defaults when the app runs inside the container.
1418
DB_HOST=127.0.0.1
19+
DB_MIGRATION_PASSWORD=MigrationOnly!Passw0rd7
20+
DB_MIGRATION_USER=kravhantering_job
1521
DB_NAME=kravhantering
22+
DB_PASSWORD=RuntimeOnly!Passw0rd7
1623
DB_PORT=1433
1724
DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7
1825
DB_READONLY_USER=readonly
26+
DB_RUNTIME_USER=kravhantering_app
1927
DB_TRUST_SERVER_CERTIFICATE=true
28+
DB_USER=kravhantering_app
2029
MSSQL_SA_PASSWORD=YourStrong!Passw0rd
2130

2231
NEXT_PUBLIC_SITE_URL=http://localhost:3000

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ DATABASE_URL=mssql://sa:YourStrong!Passw0rd@127.0.0.1:1433/kravhantering?encrypt
7070
# MSSQL_SA_PASSWORD=YourStrong!Passw0rd
7171
# DB_READONLY_USER=readonly
7272
# DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7
73+
# DB_RUNTIME_USER=kravhantering_app
7374
# DB_ENCRYPT=true
7475
# DB_TRUST_SERVER_CERTIFICATE=true
7576

.env.sqlserver.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ DB_NAME=kravhantering
1919
DB_PORT=1433
2020
DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7
2121
DB_READONLY_USER=readonly
22+
DB_RUNTIME_USER=kravhantering_app
2223
DB_TRUST_SERVER_CERTIFICATE=true
2324

2425
# Optional explicit overrides for the derived DB_* connection settings above:

containers/app/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ COPY --from=db-job-dependencies --chown=node:node /workspace/node_modules ./node
9595
COPY --chown=node:node package.json package-lock.json ./
9696
COPY --chown=node:node scripts/db-sqlserver-admin.mjs ./scripts/db-sqlserver-admin.mjs
9797
COPY --chown=node:node typeorm/migrations ./typeorm/migrations
98+
COPY --chown=node:node typeorm/runtime-permission-manifest.mjs ./typeorm/runtime-permission-manifest.mjs
9899
COPY --chown=node:node typeorm/seed-required.mjs ./typeorm/seed-required.mjs
99100
COPY --chown=node:node typeorm/ai-safety-seed-data.mjs ./typeorm/ai-safety-seed-data.mjs
100101
COPY --chown=node:node typeorm/seed-runner.mjs ./typeorm/seed-runner.mjs
@@ -118,6 +119,7 @@ COPY --from=db-job-dependencies --chown=node:node /workspace/node_modules ./node
118119
COPY --chown=node:node package.json package-lock.json ./
119120
COPY --chown=node:node scripts/db-sqlserver-admin.mjs ./scripts/db-sqlserver-admin.mjs
120121
COPY --chown=node:node typeorm/migrations ./typeorm/migrations
122+
COPY --chown=node:node typeorm/runtime-permission-manifest.mjs ./typeorm/runtime-permission-manifest.mjs
121123
COPY --chown=node:node typeorm/seed-required.mjs ./typeorm/seed-required.mjs
122124
COPY --chown=node:node typeorm/ai-safety-seed-data.mjs ./typeorm/ai-safety-seed-data.mjs
123125
COPY --chown=node:node typeorm/seed-runner.mjs ./typeorm/seed-runner.mjs

containers/db-job/.env.db-job.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ DB_PORT=1433
1616
DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7
1717
DB_READONLY_USER=readonly
1818
DB_REQUEST_TIMEOUT_MS=30000
19+
DB_RUNTIME_USER=kravhantering_app
1920
DB_TRUST_SERVER_CERTIFICATE=true
2021
DB_USER=kravhantering_job

containers/db-job/README.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,19 @@ npm run container:build:db-job
2424
The image entrypoint is `node scripts/db-sqlserver-admin.mjs`, so Compose or
2525
manual runs pass the admin command as arguments:
2626

27-
- `bootstrap` creates the database plus the app and job SQL principals.
27+
- `bootstrap` creates the database plus distinct app and job SQL principals,
28+
their `dbo` default schema, and the `kravhantering_runtime` role membership.
29+
Password rotation is outside this command's scope.
2830
- `migration-status` prints JSON evidence with expected, observed, pending and
2931
unknown TypeORM migrations without modifying the database.
30-
- `migrate` applies TypeORM migrations.
32+
- `migrate` applies TypeORM migrations, reconciles the runtime permission
33+
manifest, and fails if grants or managed-user membership do not verify.
3134
- `migrate --json` applies TypeORM migrations and prints the preflight,
32-
applied migration and post-migration evidence as JSON.
35+
migration execution, final migration status, and runtime-permission evidence
36+
as JSON.
37+
- `permission-status` prints secret-free JSON evidence without changing state.
38+
- `permission-reconcile` reapplies the manifest and managed memberships, then
39+
prints the verified JSON status.
3340
- `seed:required` applies only required system and lookup seed data.
3441
- `health` runs a simple SQL Server read check.
3542
- `wait` polls SQL Server until it responds.
@@ -39,6 +46,25 @@ A production-like empty database is bootstrap, migration, and
3946
its required seed helper modules, and excludes `typeorm/seed.mjs`, dogfood
4047
seed, archiving-retention demo seed, tests, and documentation.
4148

49+
[`runtime-permission-manifest.mjs`](../../typeorm/runtime-permission-manifest.mjs)
50+
is the release-versioned authority for exact object, operation, and
51+
column-scoped grants. New objects require manifest inclusion for access through
52+
the custom `kravhantering_runtime` role. Within the custom role, the runtime can
53+
read but not write `dbo.migrations`; protected audit and review tables have
54+
narrower insert, update-column, and delete boundaries. The reconciler removes
55+
unexpected direct permissions from the project role. For every managed runtime
56+
user, it establishes and verifies the custom grants and membership. If that
57+
user also belongs to `db_datareader` or `db_datawriter`, the reconciler removes
58+
those broad memberships only after the custom contract verifies. It does not
59+
modify other user roles, direct user grants, or site-owned extension-role
60+
memberships. Verification nevertheless fails when those permissions give a
61+
managed runtime user effective schema-migration or protected-audit mutation
62+
access. Custom-role parent nesting also fails verification for an operator to
63+
resolve explicitly. Migrations and required seed continue to use the separate
64+
db-job login with `db_owner`. Reconciliation, broad-role removal, and final
65+
effective-permission verification commit as one transaction; a final failure
66+
leaves no partial permission or membership changes.
67+
4268
The image installs only the dependency subset needed by the one-shot job:
4369
`mssql`, `typeorm`, and `reflect-metadata`. It deliberately does not include
4470
the Next.js application dependency tree.
@@ -53,6 +79,10 @@ Required values:
5379

5480
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` select the
5581
SQL Server database used for migrations and required seed data.
82+
- `DB_RUNTIME_USER` names the application runtime database user whose custom-role
83+
membership must verify. It is non-secret and never authorizes login creation,
84+
password rotation, or a runtime connection. Additional managed users may be
85+
listed comma-separated in `DB_RUNTIME_USERS`.
5686
- `DB_ENCRYPT` and `DB_TRUST_SERVER_CERTIFICATE` configure the SQL Server TLS
5787
connection.
5888
- `DB_CONNECTION_TIMEOUT_MS` and `DB_REQUEST_TIMEOUT_MS` bound database
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
# db-job SQL Server client timeouts: opening connections and running SQL.
2+
# Only set DB_BOOTSTRAP_* for the controlled bootstrap alternative or internal
3+
# single-node topology. Remove them for DBA-pre-provisioned production use.
4+
DB_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-sql-admin-password
5+
DB_BOOTSTRAP_ADMIN_USER=sa
6+
DB_BOOTSTRAP_APP_PASSWORD=replace-with-app-db-password
7+
DB_BOOTSTRAP_APP_USER=kravhantering_app
28
DB_CONNECTION_TIMEOUT_MS=15000
3-
DB_REQUEST_TIMEOUT_MS=30000
49
DB_ENCRYPT=true
510
DB_HOST=sqlserver.example.internal
611
DB_NAME=kravhantering
712
DB_PASSWORD=replace-with-db-job-password
813
DB_PORT=1433
14+
DB_REQUEST_TIMEOUT_MS=30000
15+
DB_RUNTIME_USER=kravhantering_app
916
DB_TRUST_SERVER_CERTIFICATE=false
1017
DB_USER=kravhantering_job
11-
12-
# Only set these for the controlled bootstrap alternative or the internal
13-
# single-node variant. Remove them for DBA-pre-provisioned production use.
14-
DB_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-sql-admin-password
15-
DB_BOOTSTRAP_ADMIN_USER=sa
16-
DB_BOOTSTRAP_APP_PASSWORD=replace-with-app-db-password
17-
DB_BOOTSTRAP_APP_USER=kravhantering_app

containers/production/sqlserver/dba-provision.sql.template

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/*
2-
cSpell:words datawriter
3-
42
SQL Server pre-provisioning template for the external database path.
53

64
Replace every value in angle brackets before running. The migration/job
75
principal needs db_owner because TypeORM migrations create and alter schema.
8-
The app principal needs read/write data roles for normal runtime traffic.
6+
The db-job reconciles the custom runtime role's release-versioned grants when
7+
migration completes; this template owns principals and initial membership only.
8+
The app principal receives only the custom runtime role.
99
*/
1010

1111
USE [master];
@@ -36,29 +36,30 @@ GO
3636

3737
IF DATABASE_PRINCIPAL_ID(N'<db-job-login>') IS NULL
3838
BEGIN
39-
CREATE USER [<db-job-login>] FOR LOGIN [<db-job-login>];
39+
CREATE USER [<db-job-login>] FOR LOGIN [<db-job-login>]
40+
WITH DEFAULT_SCHEMA = [dbo];
4041
END
42+
ALTER USER [<db-job-login>] WITH DEFAULT_SCHEMA = [dbo];
4143
GO
4244

4345
IF DATABASE_PRINCIPAL_ID(N'<app-login>') IS NULL
4446
BEGIN
45-
CREATE USER [<app-login>] FOR LOGIN [<app-login>];
47+
CREATE USER [<app-login>] FOR LOGIN [<app-login>]
48+
WITH DEFAULT_SCHEMA = [dbo];
4649
END
50+
ALTER USER [<app-login>] WITH DEFAULT_SCHEMA = [dbo];
4751
GO
4852

49-
IF NOT EXISTS (
53+
IF EXISTS (
5054
SELECT 1
51-
FROM sys.database_role_members AS members
52-
INNER JOIN sys.database_principals AS roles
53-
ON members.role_principal_id = roles.principal_id
54-
INNER JOIN sys.database_principals AS principals
55-
ON members.member_principal_id = principals.principal_id
56-
WHERE roles.name = N'db_owner'
57-
AND principals.name = N'<db-job-login>'
55+
FROM sys.database_principals
56+
WHERE [name] = N'kravhantering_runtime'
57+
AND [type] <> N'R'
5858
)
59-
BEGIN
60-
ALTER ROLE [db_owner] ADD MEMBER [<db-job-login>];
61-
END
59+
THROW 51021, 'Cannot provision kravhantering_runtime: a non-role database principal uses that name.', 1;
60+
61+
IF DATABASE_PRINCIPAL_ID(N'kravhantering_runtime') IS NULL
62+
CREATE ROLE [kravhantering_runtime] AUTHORIZATION [dbo];
6263

6364
IF NOT EXISTS (
6465
SELECT 1
@@ -67,11 +68,11 @@ IF NOT EXISTS (
6768
ON members.role_principal_id = roles.principal_id
6869
INNER JOIN sys.database_principals AS principals
6970
ON members.member_principal_id = principals.principal_id
70-
WHERE roles.name = N'db_datareader'
71-
AND principals.name = N'<app-login>'
71+
WHERE roles.name = N'db_owner'
72+
AND principals.name = N'<db-job-login>'
7273
)
7374
BEGIN
74-
ALTER ROLE [db_datareader] ADD MEMBER [<app-login>];
75+
ALTER ROLE [db_owner] ADD MEMBER [<db-job-login>];
7576
END
7677

7778
IF NOT EXISTS (
@@ -81,10 +82,10 @@ IF NOT EXISTS (
8182
ON members.role_principal_id = roles.principal_id
8283
INNER JOIN sys.database_principals AS principals
8384
ON members.member_principal_id = principals.principal_id
84-
WHERE roles.name = N'db_datawriter'
85+
WHERE roles.name = N'kravhantering_runtime'
8586
AND principals.name = N'<app-login>'
8687
)
8788
BEGIN
88-
ALTER ROLE [db_datawriter] ADD MEMBER [<app-login>];
89+
ALTER ROLE [kravhantering_runtime] ADD MEMBER [<app-login>];
8990
END
9091
GO

cspell.jsonc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,9 @@
386386
"executesql",
387387
"recordset",
388388
"recordsets",
389+
"rolemember",
389390
"datareader",
391+
"datawriter",
390392
"Millis",
391393
"analysability",
392394
"acceptanskriterie",

0 commit comments

Comments
 (0)