Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0087_org_settings.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | db/migrations/0087_org_settings.sql |
| Original SHA-256 | 446e5ab76c533c4b58c8b3a05b5300e39429e2cdf15221e9cd5690f4017acf78 |
| Git blob SHA | d3c207af01423bcdf07f43f3de097c97f477e4d9 |
| Reading-copy SHA-256 | 446e5ab76c533c4b58c8b3a05b5300e39429e2cdf15221e9cd5690f4017acf78 |
Recovered source transcript
Download complete source text ↓254 lines · 16.9 KB
-- +goose Up
-- PER ORGANIZATION SETTINGS AND PER ORGANIZATION SECRETS.
--
-- WHY THIS EXISTS. Three separate asks land on the same missing thing. Invites had to be delivered by hand
-- because the platform had no way to hold a customer's mail credentials. Recruiters were doubling work on the
-- same candidate because nothing recorded who was already on it. Rates were visible to everyone because
-- sensitivity had nowhere to be configured. All three need a place where a tenant states how it works, and one
-- of them needs a place where a tenant stores a password. So this is two tables, not one, because a setting
-- and a secret deserve different handling.
--
-- SECRETS ARE SEALED, NEVER STORED. org_secrets holds AES 256 GCM ciphertext under a key that lives only in
-- the server env file, carried with a key_version so the key can rotate without a flag day. That is exactly
-- the mfa_totp pattern from migration 0009, deliberately reused rather than reinvented: one definition of what
-- encrypted at rest means in this system. NOTHING in this table is a fragment of a plaintext secret. There is
-- no last_four, no hint, no length column, because each of those is a free guess for anyone who reaches the
-- row.
--
-- SETTINGS MAY NOT WEAKEN A COMPLIANCE GUARANTEE, and that bound is the reason this table is typed columns
-- rather than a key value bag. Human approval of every slate member, adverse impact reporting, the audit
-- trail, and the fixed NIST session timeouts are NOT here and must never be added here. A tenant configures
-- how its people work. A tenant does not configure whether this is still an auditable Automated Employment
-- Decision Tool. A key value bag would have made the wrong setting a one line insert; typed columns make it a
-- migration somebody has to justify.
--
-- POLICIES CARRY NO "TO" CLAUSE. Same reason as migration 0086: a policy naming only missionmatch_app does not
-- apply to missionmatch_staging_app, and with no applicable policy RLS denies everything, which is what broke
-- Gate A. An unaddressed policy applies to every role and is correct in every environment.
-- ============================================================================
-- 1. org_settings
-- ============================================================================
CREATE TABLE org_settings (
organization_id uuid PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
-- ---- outbound email -----------------------------------------------------------------------------
-- Off by default, so an organization that never configures mail keeps exactly the behaviour it has today:
-- the invite endpoint returns the one time link and a human delivers it. Turning this on is a deliberate
-- act, and it only takes effect once a test send has actually succeeded (see smtp_verified_at).
email_delivery_enabled boolean NOT NULL DEFAULT false,
email_from_address text,
email_from_name text,
email_reply_to text,
smtp_host text,
smtp_port int CHECK (smtp_port IS NULL OR (smtp_port BETWEEN 1 AND 65535)),
smtp_username text,
-- THERE IS NO PLAINTEXT OPTION. Cleartext SMTP is not offered at any port, because the two things this
-- transport carries are an activation credential and a candidate's name, and neither belongs on the wire
-- in the clear. starttls upgrades an established session, implicit_tls dials TLS immediately (port 465).
smtp_security text NOT NULL DEFAULT 'starttls'
CHECK (smtp_security IN ('starttls', 'implicit_tls')),
-- Set ONLY by a successful test send and cleared by any settings change that could invalidate it. Mail is
-- never attempted against an unverified configuration, so a typo in a host name surfaces when an admin
-- presses test, not when a candidate never receives an invite and nobody finds out for a week.
smtp_verified_at timestamptz,
smtp_last_attempt_at timestamptz,
-- The transport's own error text, kept so an admin can self serve a fix. It is a connection level message,
-- never a credential, and the application refuses to write it if it would echo the username or password.
smtp_last_error text,
-- ---- who can see what a recruiter is working on -------------------------------------------------
-- The ask this answers is double work: two recruiters calling the same person on the same day makes the
-- agency look disorganised to the one human whose opinion decides the placement. organization is the
-- default because visibility is the entire point, and the narrower settings exist for firms that run
-- competing desks and genuinely do not want a shared pipeline.
work_visibility text NOT NULL DEFAULT 'organization'
CHECK (work_visibility IN ('private', 'team', 'organization')),
-- Presence is the soft version: not a lock, just a note that somebody else is here. It is what stops most
-- collisions without stopping anybody from working.
show_activity_presence boolean NOT NULL DEFAULT true,
-- Claiming is the hard version, for firms that want exclusivity on a candidate while somebody works them.
claim_required boolean NOT NULL DEFAULT false,
-- A claim nobody releases is a claim that silently owns a candidate forever, which is worse than no
-- claiming at all, so claims expire and the default is three working days.
claim_expiry_hours int NOT NULL DEFAULT 72
CHECK (claim_expiry_hours BETWEEN 1 AND 2160),
-- ---- field sensitivity --------------------------------------------------------------------------
-- Pay data is the field where a wrong default is a real harm, not an inconvenience. A junior recruiter who
-- can see the bill rate can infer the margin, and a candidate who learns the margin is a candidate who has
-- learned what the agency makes on them. The industry default is that pay data is management visible, so
-- that is the default here.
rate_visibility text NOT NULL DEFAULT 'managers'
CHECK (rate_visibility IN ('owner', 'managers', 'team', 'organization')),
margin_visibility text NOT NULL DEFAULT 'owner'
CHECK (margin_visibility IN ('owner', 'managers', 'team', 'organization')),
-- Contact details are the other sensitive class and for the opposite reason: they are the asset a leaving
-- recruiter walks out with. team by default, so a desk sees its own people and not the whole database.
contact_visibility text NOT NULL DEFAULT 'team'
CHECK (contact_visibility IN ('owner', 'managers', 'team', 'organization')),
-- ---- operating defaults -------------------------------------------------------------------------
-- The settings nobody asks for until the day they are wrong. A deadline rendered in the wrong zone is a
-- missed submission, and this platform's whole calendar is federal due dates.
time_zone text NOT NULL DEFAULT 'America/New_York',
invite_expiry_hours int NOT NULL DEFAULT 72
CHECK (invite_expiry_hours BETWEEN 1 AND 336),
-- The role a new invite proposes. It is a default in the user interface, NOT an authority bound: the
-- separation of duties rule that an inviter may only invite strictly below themselves still decides what
-- is actually permitted, and this cannot raise it.
default_invite_role text NOT NULL DEFAULT 'recruiter',
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT fk_org_settings_updated_by FOREIGN KEY (organization_id, updated_by)
REFERENCES users (organization_id, id),
-- Delivery cannot be switched on against a configuration that has never worked. The application checks
-- this too, and says so in a sentence; the constraint is here because a check that lives only in a handler
-- is a check the second caller skips.
CONSTRAINT ck_org_settings_email_ready CHECK (
email_delivery_enabled = false
OR (smtp_host IS NOT NULL AND smtp_port IS NOT NULL
AND email_from_address IS NOT NULL AND smtp_verified_at IS NOT NULL)
)
);
ALTER TABLE org_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_settings FORCE ROW LEVEL SECURITY;
CREATE POLICY org_settings_isolation ON org_settings
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE ON org_settings TO missionmatch_app;
-- ============================================================================
-- 2. org_secrets
-- ============================================================================
-- One sealed value per (organization, name). name is a stable key the application knows, for example
-- smtp_password today and a source connector credential when in place federation lands, which is the whole
-- reason this is a general table and not two columns on org_settings.
CREATE TABLE org_secrets (
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name text NOT NULL CHECK (name ~ '^[a-z][a-z0-9_.]{1,62}$'),
ciphertext bytea NOT NULL,
nonce bytea NOT NULL,
key_version int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
PRIMARY KEY (organization_id, name),
CONSTRAINT fk_org_secrets_updated_by FOREIGN KEY (organization_id, updated_by)
REFERENCES users (organization_id, id)
);
ALTER TABLE org_secrets ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_secrets FORCE ROW LEVEL SECURITY;
CREATE POLICY org_secrets_isolation ON org_secrets
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
-- DELETE is granted here and nowhere else in this schema, and the exception is deliberate. Everywhere else a
-- removed row is a historical claim that has to stay auditable. A revoked credential is the opposite: leaving
-- the ciphertext behind after an admin removes it means the value is still one key compromise away from being
-- read. Removing a secret is recorded in audit_logs by name, never by value.
GRANT SELECT, INSERT, UPDATE, DELETE ON org_secrets TO missionmatch_app;
-- ============================================================================
-- 3. who is working what, the double work answer
-- ============================================================================
-- work_claims records that a named person is on a named record right now. It is one table across entity types
-- rather than a claimed_by column on each, because the question a recruiter asks is "is anyone on this" and
-- the answer has to be the same shape for a candidate, a requisition and an opportunity.
--
-- THIS IS NOT A SECOND OWNERSHIP COLUMN. clients and requisitions already carry owner_user_id from migration
-- 0047, with claim and reassign routes in internal/modules/recruiting/ownership.go, and that column stays
-- authoritative for whose desk a record belongs to. A claim here is a DIFFERENT and shorter lived fact: not
-- "this account is mine" but "I am on this right now, do not also call them today". The two coexist, and the
-- read surface shows both, because a candidate being on Dana's desk and Marcus actively submitting them this
-- afternoon are both true and a recruiter needs to see each. CANDIDATES are the case that has neither today,
-- and a candidate called twice in one day by the same agency is the exact complaint this closes.
--
-- It is NOT a lock. released_at and expires_at both exist, and a claim that expires simply stops being a
-- claim: nothing in the application blocks a second person from working a claimed record unless the
-- organization has set claim_required, and even then the block is a warning path with an override, because a
-- staffing firm that cannot reassign a desk at five o'clock on a deadline day is a staffing firm that loses
-- the award.
CREATE TABLE work_claims (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
entity_type text NOT NULL CHECK (entity_type IN ('candidate', 'requisition', 'opportunity', 'proposal', 'client')),
entity_id uuid NOT NULL,
user_id uuid NOT NULL,
-- What they are doing, so the note a colleague sees is useful rather than merely a name.
activity text NOT NULL DEFAULT 'working'
CHECK (activity IN ('working', 'screening', 'submitting', 'interviewing', 'negotiating')),
note text NOT NULL DEFAULT '',
claimed_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
released_at timestamptz,
released_by uuid,
PRIMARY KEY (organization_id, id),
CONSTRAINT fk_work_claims_user FOREIGN KEY (organization_id, user_id)
REFERENCES users (organization_id, id),
CONSTRAINT ck_work_claims_window CHECK (expires_at > claimed_at)
);
-- One live claim per record per organization. The partial unique index is what makes "who is on this" a single
-- row answer rather than a list to reconcile, and it is enforced by the database because two recruiters
-- pressing claim at the same instant is precisely the case a handler side check loses.
CREATE UNIQUE INDEX uq_work_claims_live ON work_claims (organization_id, entity_type, entity_id)
WHERE released_at IS NULL;
CREATE INDEX idx_work_claims_user ON work_claims (organization_id, user_id, claimed_at DESC)
WHERE released_at IS NULL;
CREATE INDEX idx_work_claims_expiry ON work_claims (expires_at)
WHERE released_at IS NULL;
ALTER TABLE work_claims ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_claims FORCE ROW LEVEL SECURITY;
CREATE POLICY work_claims_isolation ON work_claims
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE ON work_claims TO missionmatch_app;
-- ============================================================================
-- 4. lightweight presence, the soft half of the same answer
-- ============================================================================
-- work_touches is the "Dana looked at this eleven minutes ago" signal. It is separate from work_claims because
-- it answers a different question and has a different lifetime: a claim is an intention that lasts days, a
-- touch is a fact that stops being interesting within the hour.
--
-- IT IS UPSERTED, NEVER APPENDED. One row per person per record, overwritten on each visit. An append only
-- design here would generate a row per page view per recruiter per candidate, which is millions of rows a
-- month describing something nobody will ever read twice, and it would put a browsing history of a named
-- employee in a table that is trivially reportable. This is presence, not surveillance.
CREATE TABLE work_touches (
organization_id uuid NOT NULL REFERENCES organizations(id),
entity_type text NOT NULL CHECK (entity_type IN ('candidate', 'requisition', 'opportunity', 'proposal', 'client')),
entity_id uuid NOT NULL,
user_id uuid NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, entity_type, entity_id, user_id),
CONSTRAINT fk_work_touches_user FOREIGN KEY (organization_id, user_id)
REFERENCES users (organization_id, id)
);
CREATE INDEX idx_work_touches_recent ON work_touches (organization_id, entity_type, entity_id, last_seen_at DESC);
ALTER TABLE work_touches ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_touches FORCE ROW LEVEL SECURITY;
CREATE POLICY work_touches_isolation ON work_touches
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE ON work_touches TO missionmatch_app;
-- ============================================================================
-- 5. every existing organization gets its defaults now
-- ============================================================================
-- Backfilled rather than created lazily, so a read never has to decide whether an absent row means defaults or
-- means something went wrong. Provisioning inserts one too, see below.
INSERT INTO org_settings (organization_id)
SELECT id FROM organizations
ON CONFLICT (organization_id) DO NOTHING;
-- Staging parity, the same call migration 0086 ends with. Roles are cluster wide but privileges are per
-- database, so the helper is a no op unless this really is the staging database.
SELECT mm_mirror_staging_role_privileges();
-- +goose Down
DROP TABLE IF EXISTS work_touches;
DROP TABLE IF EXISTS work_claims;
DROP TABLE IF EXISTS org_secrets;
DROP TABLE IF EXISTS org_settings;