-- 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: for PIN sign-in, -- user: 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;