AD sign-in over LDAPS (search with the service account, bind as the user, nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused), roles as the union of PS-* groups mapped by a table derived from the seeded departments. Server-side sessions with idle, lock and absolute timeouts, Origin and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the domain password; PIN sign-in by employee ID on registered devices only; five wrong attempts disable the PIN, three wrong at unlock or signature end the session. Rate limits per account and per address before any bind. Devices, signatures table and sign helper, audit rows for every identity event, SSE over LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens (AD view, devices, PIN clearing, group map with missing-group flags, refresh from AD now). CLI ad-groups --list and ad-refresh. Migration 0002. Frontend: real sign-in (password tab, PIN keypad on registered devices), lock screen, role switcher, Account settings, Admin shell, screen placeholders. CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50 groups plus one extra, 17 test users; wired into the CI compose overlay and the Gitea workflow; Playwright sign-in scenario. Verified on docker-1: 171 server tests green inside the CI stack against PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against the live stack (password and PIN sign-in, lock and unlock, Admin group map). Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks only after a minute as the old build did, and the app uses the alias ps-app for Playwright because "app" is on Chromium's HSTS preload list. Co-Authored-By: Claude Fable 5.1 <[email protected]>
218 lines
8.4 KiB
PL/PgSQL
218 lines
8.4 KiB
PL/PgSQL
-- 0002_identity: departments and stations (seed), users, roles, devices, sessions, PINs,
|
|
-- sign-in attempts and signatures. Runs as ps_migrate; 0001's default privileges give ps_app
|
|
-- SELECT, INSERT, UPDATE, DELETE on every table here. Append-only tables revoke below.
|
|
|
|
CREATE TYPE role_kind AS ENUM (
|
|
'admin', 'clerical', 'clerical_supervisor', 'warehouse', 'warehouse_supervisor', 'qc',
|
|
'qc_supervisor', 'pm', 'shop_worker', 'shop_lead', 'shop_supervisor'
|
|
);
|
|
CREATE TYPE session_kind AS ENUM ('password', 'pin');
|
|
CREATE TYPE sig_factor AS ENUM ('password', 'pin');
|
|
CREATE TYPE dept_kind AS ENUM ('support', 'shop');
|
|
|
|
-- ---------------------------------------------------------------- departments and stations
|
|
|
|
CREATE TABLE departments (
|
|
code text PRIMARY KEY,
|
|
slug text UNIQUE NOT NULL,
|
|
name text NOT NULL,
|
|
kind dept_kind NOT NULL,
|
|
sort int NOT NULL,
|
|
active bool NOT NULL DEFAULT true,
|
|
supervisor_group bool NOT NULL DEFAULT true
|
|
);
|
|
|
|
-- The group names derive from code and slug, so neither may ever change.
|
|
CREATE FUNCTION ps_departments_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF NEW.code IS DISTINCT FROM OLD.code OR NEW.slug IS DISTINCT FROM OLD.slug THEN
|
|
RAISE EXCEPTION 'departments.code and departments.slug cannot change'
|
|
USING ERRCODE = 'insufficient_privilege';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER departments_code_slug_immutable
|
|
BEFORE UPDATE ON departments
|
|
FOR EACH ROW EXECUTE FUNCTION ps_departments_immutable();
|
|
|
|
CREATE TABLE stations (
|
|
id bigserial PRIMARY KEY,
|
|
dept_code text NOT NULL REFERENCES departments (code),
|
|
name text NOT NULL,
|
|
sort int NOT NULL DEFAULT 0,
|
|
active bool NOT NULL DEFAULT true,
|
|
UNIQUE (dept_code, name)
|
|
);
|
|
|
|
INSERT INTO departments (code, slug, name, kind, sort, supervisor_group) VALUES
|
|
('CL', 'Clerical', 'Clerical', 'support', 10, true),
|
|
('WH', 'Warehouse', 'Warehouse', 'support', 20, true),
|
|
('QC', 'QC', 'Q.C.', 'support', 30, true),
|
|
('PM', 'ProjectMgmt', 'Project Management', 'support', 40, false),
|
|
('01', 'Layout', 'Layout', 'shop', 50, true),
|
|
('02', 'SouthLayout', 'PESCO South Layout', 'shop', 60, true),
|
|
('03', 'WeldParts', 'Weld Parts', 'shop', 70, true),
|
|
('04', 'SouthWeld', 'PESCO South Weld', 'shop', 80, true),
|
|
('05', 'SouthWeldSS', 'PESCO South Weld SS', 'shop', 90, true),
|
|
('06', 'CodeBldg2', 'Code Bldg 2', 'shop', 100, true),
|
|
('07', 'CodeBldg3', 'Code Bldg 3', 'shop', 110, true),
|
|
('08', 'Subassembly', 'Subassembly', 'shop', 120, true),
|
|
('09', 'Hydro', 'Hydro', 'shop', 130, true),
|
|
('10', 'WeldAssembly', 'Weld Assembly', 'shop', 140, true),
|
|
('11', 'FinalAssyNorth', 'Final Assembly - North', 'shop', 150, true),
|
|
('12', 'FinalAssySouth', 'Final Assembly - South', 'shop', 160, true),
|
|
('13', 'PaintSandblast', 'Paint-Sandblast', 'shop', 170, true),
|
|
('14', 'Electrical', 'Electrical', 'shop', 180, true);
|
|
|
|
INSERT INTO stations (dept_code, name, sort)
|
|
SELECT d.code, 'Station ' || n, n
|
|
FROM departments d CROSS JOIN generate_series(1, 4) AS n
|
|
WHERE d.code IN ('01', '02', '08', '11', '12', '14');
|
|
|
|
INSERT INTO stations (dept_code, name, sort)
|
|
SELECT d.code, 'Stall ' || n, n
|
|
FROM departments d CROSS JOIN generate_series(1, 6) AS n
|
|
WHERE d.code IN ('03', '04', '05', '06', '07', '10');
|
|
|
|
INSERT INTO stations (dept_code, name, sort) VALUES
|
|
('09', 'Hydro Bay 1', 1),
|
|
('09', 'Hydro Bay 2', 2),
|
|
('13', 'Blast Booth', 1),
|
|
('13', 'Paint Booth 1', 2),
|
|
('13', 'Paint Booth 2', 3);
|
|
|
|
-- ---------------------------------------------------------------- users and roles
|
|
|
|
-- id is the AD objectGUID. emp_no stays text and is not unique: duplicates are stored and flagged.
|
|
CREATE TABLE users (
|
|
id uuid PRIMARY KEY,
|
|
sam_account text NOT NULL,
|
|
upn text,
|
|
display_name text NOT NULL,
|
|
emp_no text,
|
|
email text,
|
|
welder_stamp text,
|
|
active bool NOT NULL DEFAULT true,
|
|
directory_flags jsonb NOT NULL DEFAULT '{}',
|
|
ad_synced_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
last_signin_at timestamptz
|
|
);
|
|
|
|
CREATE UNIQUE INDEX users_sam_account_lower ON users (lower(sam_account));
|
|
CREATE INDEX users_emp_no ON users (emp_no);
|
|
|
|
CREATE TABLE user_roles (
|
|
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
role role_kind NOT NULL,
|
|
dept_code text REFERENCES departments (code),
|
|
synced_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX user_roles_unique ON user_roles (user_id, role, coalesce(dept_code, ''));
|
|
|
|
-- ---------------------------------------------------------------- devices and sessions
|
|
|
|
CREATE TABLE devices (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name text NOT NULL,
|
|
token_hash bytea UNIQUE NOT NULL,
|
|
registered_by uuid REFERENCES users (id),
|
|
registered_at timestamptz NOT NULL DEFAULT now(),
|
|
last_seen_at timestamptz,
|
|
revoked_at timestamptz,
|
|
revoked_by uuid
|
|
);
|
|
|
|
CREATE TABLE sessions (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users (id),
|
|
token_hash bytea UNIQUE NOT NULL,
|
|
kind session_kind NOT NULL,
|
|
device_id uuid REFERENCES devices (id),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL,
|
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
locked_since timestamptz,
|
|
revoked_at timestamptz,
|
|
revoked_reason text,
|
|
ip inet,
|
|
user_agent text,
|
|
roles_snapshot jsonb NOT NULL,
|
|
roles_checked_at timestamptz NOT NULL DEFAULT now(),
|
|
stepup_failures int NOT NULL DEFAULT 0,
|
|
hidden_since timestamptz
|
|
);
|
|
|
|
CREATE INDEX sessions_user_active ON sessions (user_id) WHERE revoked_at IS NULL;
|
|
CREATE INDEX sessions_device_active ON sessions (device_id) WHERE revoked_at IS NULL;
|
|
|
|
CREATE TABLE user_pins (
|
|
user_id uuid PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
|
hash text NOT NULL,
|
|
failed_count int NOT NULL DEFAULT 0,
|
|
disabled_at timestamptz,
|
|
set_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- ---------------------------------------------------------------- append-only records
|
|
|
|
-- account: lower(login) for password sign-in, emp:<emp_no> for PIN sign-in,
|
|
-- user:<uuid> for unlock, signature and pin_set.
|
|
CREATE TABLE signin_attempts (
|
|
id bigserial PRIMARY KEY,
|
|
at timestamptz NOT NULL DEFAULT now(),
|
|
account text NOT NULL,
|
|
ip inet,
|
|
kind text NOT NULL CHECK (kind IN ('password', 'pin', 'unlock', 'signature', 'pin_set')),
|
|
ok bool NOT NULL,
|
|
reason text,
|
|
user_id uuid,
|
|
session_id uuid
|
|
);
|
|
|
|
CREATE INDEX signin_attempts_account_at ON signin_attempts (account, at);
|
|
CREATE INDEX signin_attempts_ip_at ON signin_attempts (ip, at);
|
|
CREATE INDEX signin_attempts_user_at ON signin_attempts (user_id, at);
|
|
|
|
CREATE TRIGGER signin_attempts_append_only
|
|
BEFORE UPDATE OR DELETE ON signin_attempts
|
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
|
CREATE TRIGGER signin_attempts_no_truncate
|
|
BEFORE TRUNCATE ON signin_attempts
|
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
|
|
|
CREATE TABLE signatures (
|
|
id bigserial PRIMARY KEY,
|
|
at timestamptz NOT NULL DEFAULT now(),
|
|
user_id uuid NOT NULL,
|
|
session_id uuid,
|
|
account text,
|
|
display_name text,
|
|
session_kind session_kind,
|
|
factor sig_factor,
|
|
action text NOT NULL,
|
|
meaning text,
|
|
packet_id bigint,
|
|
subject_type text,
|
|
subject_id text,
|
|
content_hash bytea
|
|
);
|
|
|
|
CREATE INDEX signatures_packet ON signatures (packet_id) WHERE packet_id IS NOT NULL;
|
|
CREATE INDEX signatures_user_at ON signatures (user_id, at);
|
|
|
|
CREATE TRIGGER signatures_append_only
|
|
BEFORE UPDATE OR DELETE ON signatures
|
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
|
CREATE TRIGGER signatures_no_truncate
|
|
BEFORE TRUNCATE ON signatures
|
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
|
|
|
-- Explicit, after the default privileges: the app may only read and append these two.
|
|
REVOKE UPDATE, DELETE, TRUNCATE ON signin_attempts, signatures FROM ps_app;
|
|
GRANT SELECT, INSERT ON signin_attempts, signatures TO ps_app;
|
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
|