diff --git a/ROADMAP.md b/ROADMAP.md index 8774f46..3a7e58c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -274,12 +274,30 @@ workaround — but staying on it indefinitely is not a posture. Upgrading is an change rather than a file swap and needs its own browser testing, so it is deliberately not bundled here. -### `[ ]` 2.8 — Single shared operator credential `(F12)` +### `[x]` 2.8 — Single shared operator credential `(F12)` -One `OPERATOR_EMAIL`/`OPERATOR_PASSWORD` for the whole factory; `movements.operator` -records the same name for everyone. The meeting asked for traceability, and the old -`kanban/main.py` explicitly designed separation of duties (Mayana classifies, -Thales/Alexandre authorise). Needs real per-person accounts with roles. +Accounts now live in `dtf_local.operators`, one per person, with `movements.operator` +and `order_files.created_by` recording who actually acted. Administered from the API +container with `python -m local.operators` (list, add, password, disable, enable); +passwords are read from the terminal so they never reach shell history or the +process list, and disabling revokes open sessions immediately rather than leaving +them valid for the rest of the eight-hour window. + +Migration was the risk, since getting it wrong locks the factory out of the Kanban. +`OPERATOR_EMAIL`/`OPERATOR_PASSWORD` seed the first account, once: a password +changed through the CLI is never reverted by a stale environment variable on the +next deploy. The first attempt did not work — `db-init` was not given those +variables in either compose file, so no account would have been created and login +would have failed closed with 503. Both files now pass them to the migration job. +Verified end to end: the unchanged credential still logs in, a second operator +authenticates separately, wrong passwords and unknown accounts are rejected, and +disabling ends access at once. + +**Roles are deliberately not included.** The meeting described separation of duties +for rework authorisation (Mayana classifies, Thales or Alexandre authorise), but the +rework feature does not exist in this system, so there is nothing for a role to +gate. Building an authorisation model with no consumer would be guesswork. Add roles +with the feature that needs them. ### `[~]` 2.9 — TLS is terminated outside the repository `(F13)` diff --git a/compose.local.yaml b/compose.local.yaml index 5debf51..fa6f274 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -95,6 +95,12 @@ services: DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local} APP_DB_USER: ${APP_DB_USER:-dtf_app} APP_DB_PASSWORD: ${APP_DB_PASSWORD:-local-app-database-only} + # The migration job seeds the first operator account from these, so an + # existing deployment keeps its Kanban login after the accounts table + # lands. Without them there would be no account at all and login would + # fail closed with 503. + OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test} + OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only} networks: [local] depends_on: db: {condition: service_healthy} diff --git a/docker-compose.yml b/docker-compose.yml index f1f255a..be46ad2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,12 @@ services: DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} APP_DB_USER: dtf_app APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD} + # The migration job seeds the first operator account from these, so an + # existing deployment keeps its Kanban login after the accounts table + # lands. Without them there would be no account at all and login would + # fail closed with 503. + OPERATOR_EMAIL: ${OPERATOR_EMAIL:-} + OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} networks: [backend] deploy: replicas: 1 diff --git a/local/app.py b/local/app.py index ad470a8..3059513 100644 --- a/local/app.py +++ b/local/app.py @@ -18,7 +18,7 @@ from .secrets import load as load_secret_files from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin from .pricing import price -from .auth import COOKIE_SECURE, client_ip, owner, session_row, new_session, operator, throttle, audit, rate_limit +from .auth import COOKIE_SECURE, DUMMY_PASSWORD_HASH, client_ip, owner, session_row, new_session, operator, password_matches, throttle, audit, rate_limit from .scanning import require_clean load_secret_files() @@ -54,15 +54,17 @@ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS) @app.post('/api/operator/login') def operator_login(body: OperatorLogin, request: Request, response: Response): - configured_email = os.environ.get('OPERATOR_EMAIL', '').strip().lower() - if not configured_email: - raise HTTPException(503, 'Kanban operator email is not configured') email = body.email throttle('operator:'+email, request) - valid_user = secrets.compare_digest(email.encode(), configured_email.encode()) - valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode()) - if not (valid_user and valid_password): - audit('operator_login_failed', ip=client_ip(request)) + with db.connect() as c: + account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone() + if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone(): + raise HTTPException(503, 'No Kanban operator account is configured') + # Comparable password work whether or not the account exists or is active. + stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH + matches = password_matches(body.password, stored) + if not account or not account['active'] or not matches: + audit('operator_login_failed', ip=client_ip(request), operator=email) raise HTTPException(401, 'Invalid operator login') token = secrets.token_urlsafe(32) with db.connect() as c: @@ -70,6 +72,7 @@ def operator_login(body: OperatorLogin, request: Request, response: Response): c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,)) c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)', (hashlib.sha256(token.encode()).hexdigest(), email)) + c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],)) response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE, samesite='strict', path='/api/operator', max_age=28800) audit('operator_login_success', operator=email) diff --git a/local/bootstrap.py b/local/bootstrap.py index 38e8717..651d8fb 100644 --- a/local/bootstrap.py +++ b/local/bootstrap.py @@ -5,6 +5,7 @@ from urllib.parse import urlparse import psycopg from psycopg import sql from .secrets import load as load_secret_files +from .operators import seed_from_environment def admin_connect(): @@ -45,6 +46,13 @@ def main(): c.execute(sql.SQL('GRANT USAGE ON SCHEMA dtf_local TO {}').format(sql.Identifier(role))) c.execute(sql.SQL('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role))) c.execute(sql.SQL('GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role))) + # Turn the configured credential into a real account so an existing + # deployment keeps logging in exactly as before. Inserts only when that + # email is absent, so a password changed with local.operators is never + # reverted by a stale environment variable on the next deploy. + seeded = seed_from_environment(c) print('Local schema migrated; runtime role has DML only.') + if seeded: + print(f'Seeded operator account {seeded} from OPERATOR_EMAIL.') if __name__ == '__main__': main() diff --git a/local/operators.py b/local/operators.py new file mode 100644 index 0000000..477cd74 --- /dev/null +++ b/local/operators.py @@ -0,0 +1,130 @@ +"""Kanban operator accounts. + +One shared login meant every card movement was attributed to the same name, so +the movement history could not answer who did what. Accounts live in the +database; `OPERATOR_EMAIL` and `OPERATOR_PASSWORD` seed the first one so an +existing deployment keeps working unchanged. + +Administered from the API container, the same way the schema is: + + python -m local.operators list + python -m local.operators add maria@example.com --name "Maria" + python -m local.operators password maria@example.com + python -m local.operators disable maria@example.com + python -m local.operators enable maria@example.com + +Passwords are read from the terminal, never from an argument, so they do not +reach shell history or the process list. +""" +import getpass +import os +import sys +from uuid import uuid4 + +from .auth import password_hash +from .db import connect + +MIN_PASSWORD = 12 + + +def seed_from_environment(cursor): + """Make the configured credential a real account, once. + + Only inserts when that email is absent, so a password changed here is never + reverted by a stale environment variable on the next deploy. + """ + email = os.environ.get('OPERATOR_EMAIL', '').strip().lower() + password = os.environ.get('OPERATOR_PASSWORD', '') + if not email or not password: + return None + existing = cursor.execute( + 'SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone() + if existing: + return None + cursor.execute( + 'INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)', + (uuid4(), email, 'Operador', password_hash(password))) + return email + + +def _ask_password(email): + first = getpass.getpass(f'New password for {email}: ') + if len(first) < MIN_PASSWORD: + raise SystemExit(f'Password must be at least {MIN_PASSWORD} characters.') + if first != getpass.getpass('Repeat: '): + raise SystemExit('Passwords did not match.') + return first + + +def add(email, name=''): + email = email.strip().lower() + with connect() as c: + if c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone(): + raise SystemExit(f'{email} already exists. Use "password" or "enable".') + c.execute('INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)', + (uuid4(), email, name, password_hash(_ask_password(email)))) + print(f'Added {email}.') + + +def password(email): + email = email.strip().lower() + with connect() as c: + if not c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone(): + raise SystemExit(f'{email} does not exist.') + c.execute('UPDATE dtf_local.operators SET password_hash=%s WHERE email=%s', + (password_hash(_ask_password(email)), email)) + print(f'Password changed for {email}. Existing sessions stay valid until they expire;' + ' run "disable" first if the account is compromised.') + + +def set_active(email, active): + email = email.strip().lower() + with connect() as c: + updated = c.execute( + 'UPDATE dtf_local.operators SET active=%s WHERE email=%s RETURNING email', + (active, email)).fetchone() + if not updated: + raise SystemExit(f'{email} does not exist.') + if not active: + # Revoke immediately: disabling must end access now, not in eight hours. + c.execute('DELETE FROM dtf_local.operator_sessions WHERE username=%s', (email,)) + print(f'{email} {"enabled" if active else "disabled; open sessions revoked"}.') + + +def listing(): + with connect() as c: + rows = c.execute('''SELECT email,name,active,last_login_at FROM dtf_local.operators + ORDER BY email''').fetchall() + if not rows: + print('No operator accounts. Set OPERATOR_EMAIL and OPERATOR_PASSWORD and run' + ' "python -m local.bootstrap", or add one here.') + return + for row in rows: + seen = row['last_login_at'].strftime('%Y-%m-%d %H:%M') if row['last_login_at'] else 'never' + state = 'active ' if row['active'] else 'DISABLED' + print(f"{state} {row['email']:<34} {row['name'][:20]:<20} last login {seen}") + + +def main(argv): + if not argv: + raise SystemExit(__doc__) + command, rest = argv[0], argv[1:] + if command == 'list': + return listing() + if not rest: + raise SystemExit(f'usage: python -m local.operators {command} ') + email = rest[0] + if command == 'add': + name = rest[rest.index('--name') + 1] if '--name' in rest else '' + return add(email, name) + if command == 'password': + return password(email) + if command == 'disable': + return set_active(email, False) + if command == 'enable': + return set_active(email, True) + raise SystemExit(__doc__) + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/local/runtime_security_test.py b/local/runtime_security_test.py index 0f7142c..dafef74 100644 --- a/local/runtime_security_test.py +++ b/local/runtime_security_test.py @@ -26,8 +26,34 @@ def check_client_ip(): assert client_ip(FakeRequest(peer=None))=='unknown' print('PASS: client address resolution for rate-limit buckets and audit events') +def check_operator_accounts(): + """Accounts are per person, and disabling one ends its access at once.""" + from uuid import uuid4 + from .auth import password_hash, password_matches + from .operators import seed_from_environment, set_active + email='runtime-check-'+uuid4().hex[:8]+'@example.test' + with connect() as c: + c.execute('INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)', + (uuid4(), email, 'Runtime Check', password_hash('runtime-check-password'))) + row=c.execute('SELECT * FROM dtf_local.operators WHERE email=%s',(email,)).fetchone() + assert row['active'] and password_matches('runtime-check-password', row['password_hash']) + assert not password_matches('wrong', row['password_hash']) + # Seeding is once-only: a password changed here must survive a redeploy. + c.execute("INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)", + ('runtime-check-'+uuid4().hex, email)) + set_active(email, False) + with connect() as c: + row=c.execute('SELECT active FROM dtf_local.operators WHERE email=%s',(email,)).fetchone() + sessions=c.execute('SELECT count(*) AS n FROM dtf_local.operator_sessions WHERE username=%s', + (email,)).fetchone()['n'] + assert not row['active'], 'disable did not deactivate' + assert sessions==0, 'disable left an open session behind' + c.execute('DELETE FROM dtf_local.operators WHERE email=%s',(email,)) + print('PASS: per-operator accounts, password verification, immediate revocation on disable') + def run(): check_client_ip() + check_operator_accounts() with connect() as c: role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone() assert not any(role.values()),role diff --git a/local/schema.sql b/local/schema.sql index 74eedb1..7f46b34 100644 --- a/local/schema.sql +++ b/local/schema.sql @@ -59,6 +59,11 @@ 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()