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;
|