Files
ResolveSpec/pkg/security/lookup/ddl/mssql.sql
T
Hein c9fa8c60f2 refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).

- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
  podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
  parameter, JSON null arrays, expires_at timezone casts, passkey list
  GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
2026-10-01 13:19:44 +02:00

260 lines
9.4 KiB
SQL

-- Reference schema for the lookup direct backend: Microsoft SQL Server 2016+. Direct backend only. Run each statement separately (see ddl.Statements).
-- Table and column names are the lookup.DefaultSchema defaults, override them with lookup.Config.Schema.
-- Generated by the project, edit freely for your deployment (types, collations, extra columns).
-- password: bcrypt hash (nullable for OAuth2 users), legacy cleartext is accepted at login
-- roles: comma-separated roles
IF OBJECT_ID(N'users', N'U') IS NULL
CREATE TABLE users (
id INT IDENTITY(1,1) PRIMARY KEY,
username NVARCHAR(255) NOT NULL UNIQUE,
email NVARCHAR(255) NOT NULL UNIQUE,
password NVARCHAR(255),
user_level INT DEFAULT 0,
roles NVARCHAR(500),
is_active BIT DEFAULT 1,
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2 DEFAULT SYSUTCDATETIME(),
last_login_at DATETIME2,
program_user_id INT DEFAULT 0,
program_user_table NVARCHAR(255) DEFAULT '',
remote_id NVARCHAR(255),
auth_provider NVARCHAR(50),
totp_secret NVARCHAR(255),
totp_enabled BIT DEFAULT 0,
totp_enabled_at DATETIME2
);
IF OBJECT_ID(N'user_sessions', N'U') IS NULL
CREATE TABLE user_sessions (
id INT IDENTITY(1,1) PRIMARY KEY,
session_token NVARCHAR(450) NOT NULL UNIQUE,
user_id INT NOT NULL,
expires_at DATETIME2 NOT NULL,
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
last_activity_at DATETIME2 DEFAULT SYSUTCDATETIME(),
ip_address NVARCHAR(45),
user_agent NVARCHAR(MAX),
access_token NVARCHAR(MAX),
refresh_token NVARCHAR(MAX),
token_type NVARCHAR(50) DEFAULT 'Bearer',
auth_provider NVARCHAR(50),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_user_sessions_user_id' AND object_id = OBJECT_ID(N'user_sessions'))
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_user_sessions_expires_at' AND object_id = OBJECT_ID(N'user_sessions'))
CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at);
IF OBJECT_ID(N'token_blacklist', N'U') IS NULL
CREATE TABLE token_blacklist (
id INT IDENTITY(1,1) PRIMARY KEY,
token NVARCHAR(500) NOT NULL,
user_id INT,
expires_at DATETIME2 NOT NULL,
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- code_hash: SHA-256 hex of the backup code
IF OBJECT_ID(N'user_totp_backup_codes', N'U') IS NULL
CREATE TABLE user_totp_backup_codes (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
code_hash NVARCHAR(64) NOT NULL,
used BIT DEFAULT 0,
used_at DATETIME2,
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_totp_user_id' AND object_id = OBJECT_ID(N'user_totp_backup_codes'))
CREATE INDEX idx_totp_user_id ON user_totp_backup_codes(user_id);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_totp_code_hash' AND object_id = OBJECT_ID(N'user_totp_backup_codes'))
CREATE INDEX idx_totp_code_hash ON user_totp_backup_codes(code_hash);
-- credential_id: base64 text
-- public_key: base64 text
-- aaguid: base64 text
-- transports: JSON-encoded array
IF OBJECT_ID(N'user_passkey_credentials', N'U') IS NULL
CREATE TABLE user_passkey_credentials (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
credential_id VARCHAR(900) NOT NULL UNIQUE,
public_key NVARCHAR(MAX) NOT NULL,
attestation_type NVARCHAR(50) DEFAULT 'none',
aaguid NVARCHAR(MAX),
sign_count INT DEFAULT 0,
clone_warning BIT DEFAULT 0,
transports NVARCHAR(MAX),
backup_eligible BIT DEFAULT 0,
backup_state BIT DEFAULT 0,
name NVARCHAR(255),
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
last_used_at DATETIME2 DEFAULT SYSUTCDATETIME(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_passkey_user_id' AND object_id = OBJECT_ID(N'user_passkey_credentials'))
CREATE INDEX idx_passkey_user_id ON user_passkey_credentials(user_id);
-- token_hash: SHA-256 hex of the raw token
IF OBJECT_ID(N'user_password_resets', N'U') IS NULL
CREATE TABLE user_password_resets (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
token_hash NVARCHAR(64) NOT NULL UNIQUE,
expires_at DATETIME2 NOT NULL,
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
used BIT DEFAULT 0,
used_at DATETIME2,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_pw_reset_user_id' AND object_id = OBJECT_ID(N'user_password_resets'))
CREATE INDEX idx_pw_reset_user_id ON user_password_resets(user_id);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_pw_reset_expires_at' AND object_id = OBJECT_ID(N'user_password_resets'))
CREATE INDEX idx_pw_reset_expires_at ON user_password_resets(expires_at);
-- redirect_uris: JSON-encoded array
-- grant_types: JSON-encoded array
-- allowed_scopes: JSON-encoded array
-- client_secret_hash: SHA-256 hex of the confidential-client secret, NULL for public clients
IF OBJECT_ID(N'oauth_clients', N'U') IS NULL
CREATE TABLE oauth_clients (
id INT IDENTITY(1,1) PRIMARY KEY,
client_id NVARCHAR(255) NOT NULL UNIQUE,
redirect_uris NVARCHAR(MAX) NOT NULL,
client_name NVARCHAR(255),
grant_types NVARCHAR(MAX),
allowed_scopes NVARCHAR(MAX),
client_secret_hash NVARCHAR(MAX),
token_endpoint_auth_method NVARCHAR(30) DEFAULT 'none',
is_active BIT DEFAULT 1,
created_at DATETIME2 DEFAULT SYSUTCDATETIME()
);
-- scopes: JSON-encoded array
IF OBJECT_ID(N'oauth_codes', N'U') IS NULL
CREATE TABLE oauth_codes (
id INT IDENTITY(1,1) PRIMARY KEY,
code NVARCHAR(255) NOT NULL UNIQUE,
client_id NVARCHAR(255) NOT NULL,
redirect_uri NVARCHAR(MAX) NOT NULL,
client_state NVARCHAR(MAX),
code_challenge NVARCHAR(255) NOT NULL,
code_challenge_method NVARCHAR(10) DEFAULT 'S256',
session_token NVARCHAR(MAX) NOT NULL,
refresh_token NVARCHAR(MAX),
scopes NVARCHAR(MAX),
expires_at DATETIME2 NOT NULL,
created_at DATETIME2 DEFAULT SYSUTCDATETIME()
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_codes_expires' AND object_id = OBJECT_ID(N'oauth_codes'))
CREATE INDEX idx_oauth_codes_expires ON oauth_codes(expires_at);
-- key_hash: SHA-256 hex
-- scopes: JSON-encoded array
-- meta: JSON-encoded object
IF OBJECT_ID(N'user_keys', N'U') IS NULL
CREATE TABLE user_keys (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
key_type NVARCHAR(50) NOT NULL,
key_hash NVARCHAR(64) NOT NULL UNIQUE,
name NVARCHAR(255) NOT NULL DEFAULT '',
scopes NVARCHAR(MAX),
meta NVARCHAR(MAX),
expires_at DATETIME2,
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
last_used_at DATETIME2,
is_active BIT DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_user_keys_user_id' AND object_id = OBJECT_ID(N'user_keys'))
CREATE INDEX idx_user_keys_user_id ON user_keys(user_id);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_user_keys_key_type' AND object_id = OBJECT_ID(N'user_keys'))
CREATE INDEX idx_user_keys_key_type ON user_keys(key_type);
-- Optional: omit to use per-user rules only.
IF OBJECT_ID(N'sec_group_members', N'U') IS NULL
CREATE TABLE sec_group_members (
group_id INT NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (group_id, user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- column_path: dot path under the table: col or col.sub.field
-- access_type: mask, hide, read, ...
-- extra_filters: JSON object
IF OBJECT_ID(N'sec_column_rules', N'U') IS NULL
CREATE TABLE sec_column_rules (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT,
group_id INT,
schema_name NVARCHAR(255) NOT NULL,
table_name NVARCHAR(255) NOT NULL,
column_path NVARCHAR(255) NOT NULL,
access_type NVARCHAR(50) NOT NULL,
mask_start INT DEFAULT 0,
mask_end INT DEFAULT 0,
mask_invert BIT DEFAULT 0,
mask_char NVARCHAR(10) DEFAULT '*',
extra_filters NVARCHAR(MAX),
is_active BIT NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK ((user_id IS NULL AND group_id IS NOT NULL) OR (user_id IS NOT NULL AND group_id IS NULL))
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_sec_column_rules_table' AND object_id = OBJECT_ID(N'sec_column_rules'))
CREATE INDEX idx_sec_column_rules_table ON sec_column_rules(schema_name, table_name);
-- template: SQL fragment, e.g. user_id = {UserID}
IF OBJECT_ID(N'sec_row_rules', N'U') IS NULL
CREATE TABLE sec_row_rules (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT,
group_id INT,
schema_name NVARCHAR(255) NOT NULL,
table_name NVARCHAR(255) NOT NULL,
template NVARCHAR(MAX),
has_block BIT NOT NULL DEFAULT 0,
is_active BIT NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK ((user_id IS NULL AND group_id IS NOT NULL) OR (user_id IS NOT NULL AND group_id IS NULL))
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_sec_row_rules_table' AND object_id = OBJECT_ID(N'sec_row_rules'))
CREATE INDEX idx_sec_row_rules_table ON sec_row_rules(schema_name, table_name);