All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m17s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m37s
One OPERATOR_EMAIL and OPERATOR_PASSWORD served the whole factory, so every card movement recorded the same name and the movement history could not answer who did what. Traceability was one of the things the project set out to provide. Accounts live in dtf_local.operators, authenticated with the same scrypt hashing as customer accounts and with comparable work whether or not the account exists, so absence is not observable by timing. Administration is a CLI in the API container, like the schema migration: list, add, password, disable, enable. Passwords are read from the terminal rather than an argument so they stay out of shell history and the process list, and disabling deletes that operator's open sessions instead of leaving them valid for the rest of the eight-hour window. Migration is the part that could hurt: an empty table means 503 and a factory locked out of its Kanban. OPERATOR_EMAIL and OPERATOR_PASSWORD seed the first account, and only when that email is absent, so a password changed through the CLI survives a redeploy carrying a stale environment variable. The first attempt at this silently did nothing, because db-init receives its own small environment and had neither variable; both compose files now pass them to it. Verified against a running stack: bootstrap seeds the existing credential, that credential still logs in unchanged, a second operator authenticates separately, wrong passwords and unknown accounts are rejected alike, and disabling revokes an open session immediately. Roles are left out on purpose. The separation of duties the meeting described governs rework authorisation, which this system does not implement, so a role model would have no consumer to serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87 lines
4.6 KiB
SQL
87 lines
4.6 KiB
SQL
-- Separate schema: never imports/migrates the historical schema.sql or SQLite.
|
|
CREATE SCHEMA IF NOT EXISTS dtf_local;
|
|
CREATE TABLE IF NOT EXISTS dtf_local.uploads (
|
|
id uuid PRIMARY KEY, owner uuid NOT NULL, name text NOT NULL,
|
|
size bigint NOT NULL, object_key text UNIQUE NOT NULL, multipart_id text NOT NULL,
|
|
complete boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.quotes (
|
|
id uuid PRIMARY KEY, owner uuid NOT NULL, request_key uuid NOT NULL,
|
|
request_hash text NOT NULL, draft jsonb NOT NULL, approved jsonb,
|
|
reviewed_by text, approved_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(owner, request_key)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.orders (
|
|
id uuid PRIMARY KEY, number bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
|
|
quote_id uuid NOT NULL UNIQUE REFERENCES dtf_local.quotes(id), owner uuid NOT NULL,
|
|
snapshot jsonb NOT NULL, payment jsonb NOT NULL, state text NOT NULL DEFAULT 'rec',
|
|
version integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.movements (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
|
|
from_state text NOT NULL, to_state text NOT NULL, operator text NOT NULL,
|
|
reason text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.outbox (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_key text NOT NULL UNIQUE,
|
|
provider text NOT NULL, payload jsonb NOT NULL, attempts integer NOT NULL DEFAULT 0,
|
|
available_at timestamptz NOT NULL DEFAULT now(), delivered_at timestamptz,
|
|
last_error text, receipt jsonb
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.accounts (
|
|
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, password_hash text NOT NULL,
|
|
profile jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.sessions (
|
|
id uuid PRIMARY KEY, owner uuid NOT NULL,
|
|
expires_at timestamptz NOT NULL DEFAULT now() + interval '7 days'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.migrations (name text PRIMARY KEY);
|
|
-- Preserve pre-account guest sessions once, without resurrecting logged-out sessions.
|
|
DO $$ BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM dtf_local.migrations WHERE name='customer-sessions-v1') THEN
|
|
INSERT INTO dtf_local.sessions(id,owner)
|
|
SELECT owner,owner FROM (
|
|
SELECT owner FROM dtf_local.orders UNION SELECT owner FROM dtf_local.quotes
|
|
UNION SELECT owner FROM dtf_local.uploads
|
|
) legacy ON CONFLICT DO NOTHING;
|
|
INSERT INTO dtf_local.migrations VALUES('customer-sessions-v1');
|
|
END IF;
|
|
END $$;
|
|
CREATE TABLE IF NOT EXISTS dtf_local.login_attempts (
|
|
key text PRIMARY KEY, attempts integer NOT NULL DEFAULT 0,
|
|
started_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.operator_sessions (
|
|
token_hash text PRIMARY KEY, username text NOT NULL,
|
|
expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.operators (
|
|
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, name text NOT NULL DEFAULT '',
|
|
password_hash text NOT NULL, active boolean NOT NULL DEFAULT true,
|
|
created_at timestamptz NOT NULL DEFAULT now(), last_login_at timestamptz
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dtf_local.security_events (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
event text NOT NULL, details jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
|
|
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS expires_at timestamptz;
|
|
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS purged_at timestamptz;
|
|
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_state text NOT NULL DEFAULT 'pending';
|
|
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_reason text;
|
|
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scanned_at timestamptz;
|
|
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_after timestamptz NOT NULL DEFAULT now();
|
|
UPDATE dtf_local.uploads SET expires_at=created_at + interval '30 days' WHERE expires_at IS NULL;
|
|
ALTER TABLE dtf_local.uploads ALTER COLUMN expires_at SET DEFAULT now() + interval '30 days';
|
|
CREATE TABLE IF NOT EXISTS dtf_local.order_files (
|
|
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
|
|
upload_id uuid NOT NULL REFERENCES dtf_local.uploads(id), item_index integer NOT NULL,
|
|
kind text NOT NULL CHECK(kind IN ('final','correction')), active boolean NOT NULL DEFAULT true,
|
|
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE(order_id,upload_id,kind)
|
|
);
|