260 lines
11 KiB
PL/PgSQL
260 lines
11 KiB
PL/PgSQL
BEGIN;
|
|||
|
|
|
||
|
|
ALTER TABLE device
|
||
|
|
ADD COLUMN enrollment_mode text NOT NULL DEFAULT 'legacy' CHECK (enrollment_mode IN ('legacy','physical_confirm')),
|
||
|
|
ADD COLUMN factory_device_id varchar(32) UNIQUE,
|
||
|
|
ADD COLUMN factory_mac char(12) UNIQUE,
|
||
|
|
ADD COLUMN binding_generation bigint NOT NULL DEFAULT 0 CHECK (binding_generation >= 0),
|
||
|
|
ADD COLUMN onboarding_revision bigint NOT NULL DEFAULT 1 CHECK (onboarding_revision > 0),
|
||
|
|
ADD COLUMN onboarding_released_at timestamptz,
|
||
|
|
ADD COLUMN onboarding_boot_id uuid,
|
||
|
|
ADD COLUMN onboarding_last_seen_at timestamptz;
|
||
|
|
|
||
|
|
ALTER TABLE device_binding ADD COLUMN generation bigint;
|
||
|
|
WITH history AS (
|
||
|
|
SELECT id, 2 * row_number() OVER (PARTITION BY device_id ORDER BY bound_at,id) - 1 AS generation
|
||
|
|
FROM device_binding
|
||
|
|
) UPDATE device_binding b SET generation=h.generation FROM history h WHERE b.id=h.id;
|
||
|
|
UPDATE device d SET binding_generation=COALESCE((SELECT max(b.generation + CASE WHEN b.unbound_at IS NULL THEN 0 ELSE 1 END) FROM device_binding b WHERE b.device_id=d.id),0);
|
||
|
|
CREATE UNIQUE INDEX device_binding_generation_key ON device_binding(device_id,generation);
|
||
|
|
|
||
|
|
CREATE TABLE device_onboarding_key (
|
||
|
|
device_id uuid NOT NULL,
|
||
|
|
tenant_id uuid NOT NULL,
|
||
|
|
key_version bigint NOT NULL CHECK (key_version > 0),
|
||
|
|
ciphertext bytea NOT NULL,
|
||
|
|
status text NOT NULL CHECK (status IN ('PENDING','ACTIVE','REVOKED')),
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
activated_at timestamptz,
|
||
|
|
PRIMARY KEY(device_id,key_version),
|
||
|
|
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
|
||
|
|
);
|
||
|
|
CREATE UNIQUE INDEX device_onboarding_one_active_key ON device_onboarding_key(device_id) WHERE status='ACTIVE';
|
||
|
|
|
||
|
|
CREATE TABLE device_enrollment_job (
|
||
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
tenant_id uuid NOT NULL,
|
||
|
|
device_id uuid NOT NULL,
|
||
|
|
actor_id uuid NOT NULL REFERENCES user_account(id),
|
||
|
|
request_key varchar(160) NOT NULL,
|
||
|
|
request_digest char(64) NOT NULL,
|
||
|
|
batch_no varchar(120) NOT NULL,
|
||
|
|
key_version bigint NOT NULL DEFAULT 1,
|
||
|
|
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','DELIVERED','RELEASED','CANCELLED')),
|
||
|
|
receiver_key text,
|
||
|
|
delivery_ciphertext bytea,
|
||
|
|
delivery_expires_at timestamptz,
|
||
|
|
evidence_ref text,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
|
||
|
|
UNIQUE(tenant_id,actor_id,request_key)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE device_time_challenge (
|
||
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
device_id uuid NOT NULL REFERENCES device(id),
|
||
|
|
key_version bigint NOT NULL,
|
||
|
|
boot_id uuid NOT NULL,
|
||
|
|
client_nonce varchar(32) NOT NULL,
|
||
|
|
server_time_ms bigint NOT NULL,
|
||
|
|
expires_at timestamptz NOT NULL,
|
||
|
|
consumed_by uuid,
|
||
|
|
UNIQUE(device_id,key_version,boot_id,client_nonce)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE device_setup_session (
|
||
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
device_id uuid NOT NULL,
|
||
|
|
tenant_id uuid NOT NULL,
|
||
|
|
boot_id uuid NOT NULL,
|
||
|
|
local_setup_id uuid NOT NULL,
|
||
|
|
binding_generation bigint NOT NULL,
|
||
|
|
mode text NOT NULL CHECK(mode IN ('ENROLL','RECOVERY')),
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
expires_at timestamptz NOT NULL,
|
||
|
|
closed_at timestamptz,
|
||
|
|
close_reason text,
|
||
|
|
web_ack_binding_id uuid,
|
||
|
|
web_ack_generation bigint,
|
||
|
|
web_ack_revision bigint,
|
||
|
|
web_ack_at timestamptz,
|
||
|
|
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
|
||
|
|
);
|
||
|
|
CREATE INDEX device_setup_device_idx ON device_setup_session(device_id,created_at DESC);
|
||
|
|
|
||
|
|
CREATE TABLE device_handoff (
|
||
|
|
token_hash char(64) PRIMARY KEY,
|
||
|
|
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
|
||
|
|
code_challenge varchar(64) NOT NULL,
|
||
|
|
expires_at timestamptz NOT NULL,
|
||
|
|
consumed_at timestamptz
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE onboarding_web_session (
|
||
|
|
token_hash char(64) PRIMARY KEY,
|
||
|
|
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
|
||
|
|
csrf_hash char(64) NOT NULL,
|
||
|
|
user_id uuid REFERENCES user_account(id),
|
||
|
|
issuer text,
|
||
|
|
subject text,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
expires_at timestamptz NOT NULL,
|
||
|
|
revoked_at timestamptz
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE device_binding_claim (
|
||
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
device_id uuid NOT NULL,
|
||
|
|
tenant_id uuid NOT NULL,
|
||
|
|
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
|
||
|
|
user_id uuid NOT NULL REFERENCES user_account(id),
|
||
|
|
household_id uuid NOT NULL,
|
||
|
|
subject_id uuid NOT NULL,
|
||
|
|
expected_generation bigint NOT NULL,
|
||
|
|
challenge_hash char(64) NOT NULL,
|
||
|
|
challenge_ciphertext bytea NOT NULL,
|
||
|
|
binding_digest char(64) NOT NULL,
|
||
|
|
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','CONFIRMED','CANCELLED','EXPIRED')),
|
||
|
|
expires_at timestamptz NOT NULL,
|
||
|
|
press_event_id uuid,
|
||
|
|
binding_id uuid REFERENCES device_binding(id),
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
completed_at timestamptz,
|
||
|
|
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
|
||
|
|
FOREIGN KEY(household_id,tenant_id) REFERENCES household(id,tenant_id),
|
||
|
|
FOREIGN KEY(subject_id,tenant_id) REFERENCES subject_profile(id,tenant_id),
|
||
|
|
UNIQUE(device_id,press_event_id)
|
||
|
|
);
|
||
|
|
CREATE UNIQUE INDEX device_binding_claim_one_pending ON device_binding_claim(device_id) WHERE status='PENDING';
|
||
|
|
|
||
|
|
CREATE TABLE device_onboarding_request (
|
||
|
|
actor_key text NOT NULL,
|
||
|
|
scope text NOT NULL,
|
||
|
|
request_id uuid NOT NULL,
|
||
|
|
request_digest char(64) NOT NULL,
|
||
|
|
device_id uuid NOT NULL REFERENCES device(id),
|
||
|
|
binding_generation bigint NOT NULL,
|
||
|
|
response_ciphertext bytea NOT NULL,
|
||
|
|
expires_at timestamptz NOT NULL,
|
||
|
|
PRIMARY KEY(actor_key,scope,request_id)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE device_state_snapshot (
|
||
|
|
device_id uuid NOT NULL REFERENCES device(id),
|
||
|
|
revision bigint NOT NULL,
|
||
|
|
binding_generation bigint NOT NULL,
|
||
|
|
state_hash char(64) NOT NULL,
|
||
|
|
state jsonb NOT NULL,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
PRIMARY KEY(device_id,revision)
|
||
|
|
);
|
||
|
|
CREATE TABLE device_state_ack (
|
||
|
|
device_id uuid PRIMARY KEY REFERENCES device(id),
|
||
|
|
revision bigint NOT NULL,
|
||
|
|
binding_generation bigint NOT NULL,
|
||
|
|
state_hash char(64) NOT NULL,
|
||
|
|
boot_id uuid NOT NULL,
|
||
|
|
local_generation bigint NOT NULL CHECK(local_generation >= 0),
|
||
|
|
accepted_at timestamptz NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE device_activation_job (
|
||
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
device_id uuid NOT NULL UNIQUE,
|
||
|
|
tenant_id uuid NOT NULL,
|
||
|
|
provider_device_name varchar(128) NOT NULL UNIQUE,
|
||
|
|
status text NOT NULL DEFAULT 'NOT_STARTED' CHECK(status IN ('NOT_STARTED','RESERVED','CONTACT_POSSIBLE','REGISTERED_OBSERVED','READY','RECONCILE_REQUIRED','BLOCKED')),
|
||
|
|
binding_generation bigint NOT NULL,
|
||
|
|
config_version bigint NOT NULL DEFAULT 1,
|
||
|
|
config_ciphertext bytea,
|
||
|
|
config_digest char(64),
|
||
|
|
config_expires_at timestamptz,
|
||
|
|
config_ack_at timestamptz,
|
||
|
|
engine_ready_at timestamptz,
|
||
|
|
current_attempt_id uuid,
|
||
|
|
last_error_code text,
|
||
|
|
reconciliation_evidence text,
|
||
|
|
version bigint NOT NULL DEFAULT 1,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
|
||
|
|
);
|
||
|
|
CREATE TABLE device_activation_attempt (
|
||
|
|
id uuid PRIMARY KEY,
|
||
|
|
activation_id uuid NOT NULL REFERENCES device_activation_job(id),
|
||
|
|
executor text NOT NULL CHECK(executor IN ('SERVER','DEVICE')),
|
||
|
|
stage text NOT NULL CHECK(stage IN ('SERVER_REGISTER','ENGINE_INIT','CONVERSATION_TEST')),
|
||
|
|
binding_generation bigint NOT NULL,
|
||
|
|
config_version bigint NOT NULL,
|
||
|
|
status text NOT NULL CHECK(status IN ('CONTACT_POSSIBLE','SUCCESS_OBSERVED','DEFINITE_FAILURE','UNKNOWN')),
|
||
|
|
permit_expires_at timestamptz NOT NULL,
|
||
|
|
permit_hash char(64),
|
||
|
|
conversation_id uuid,
|
||
|
|
sdk_result integer,
|
||
|
|
evidence jsonb,
|
||
|
|
result_digest char(64),
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
completed_at timestamptz
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE onboarding_sms_challenge (
|
||
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
session_hash char(64) NOT NULL REFERENCES onboarding_web_session(token_hash),
|
||
|
|
phone_digest char(64) NOT NULL,
|
||
|
|
phone_ciphertext bytea NOT NULL,
|
||
|
|
code_hash char(64) NOT NULL,
|
||
|
|
attempts integer NOT NULL DEFAULT 0,
|
||
|
|
status text NOT NULL DEFAULT 'SENDING' CHECK(status IN ('SENDING','SENT','FAILED','CONSUMED')),
|
||
|
|
request_id uuid NOT NULL,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
expires_at timestamptz NOT NULL,
|
||
|
|
consumed_at timestamptz,
|
||
|
|
UNIQUE(session_hash,request_id)
|
||
|
|
);
|
||
|
|
CREATE INDEX onboarding_sms_phone_time ON onboarding_sms_challenge(phone_digest,created_at DESC);
|
||
|
|
|
||
|
|
-- Every binding writer, including privacy deletion, advances the same generation.
|
||
|
|
-- Inserting ownership for a new-protocol device additionally needs a pending claim.
|
||
|
|
CREATE FUNCTION maintain_device_binding_generation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||
|
|
DECLARE d device%ROWTYPE; claim_setting text; is_changed boolean;
|
||
|
|
BEGIN
|
||
|
|
SELECT * INTO d FROM device WHERE id=COALESCE(NEW.device_id,OLD.device_id) FOR UPDATE;
|
||
|
|
IF TG_OP='INSERT' THEN
|
||
|
|
IF d.enrollment_mode='physical_confirm' THEN
|
||
|
|
claim_setting := current_setting('app.onboarding_claim',true);
|
||
|
|
IF NOT EXISTS(SELECT 1 FROM device_binding_claim c WHERE c.id::text=claim_setting
|
||
|
|
AND c.device_id=NEW.device_id AND c.status='PENDING' AND c.expires_at>now()
|
||
|
|
AND c.expected_generation=d.binding_generation AND c.user_id=NEW.bound_by
|
||
|
|
AND c.household_id=NEW.household_id AND c.subject_id=NEW.subject_id) THEN
|
||
|
|
RAISE EXCEPTION 'DEVICE_CONFIRMATION_REQUIRED' USING ERRCODE='P0001';
|
||
|
|
END IF;
|
||
|
|
END IF;
|
||
|
|
NEW.generation := d.binding_generation+1;
|
||
|
|
is_changed := true;
|
||
|
|
ELSE
|
||
|
|
IF NEW.device_id<>OLD.device_id OR NEW.household_id<>OLD.household_id OR NEW.subject_id<>OLD.subject_id OR NEW.generation IS DISTINCT FROM OLD.generation THEN
|
||
|
|
RAISE EXCEPTION 'binding identity is immutable' USING ERRCODE='P0001';
|
||
|
|
END IF;
|
||
|
|
IF OLD.unbound_at IS NOT NULL AND NEW.unbound_at IS NULL THEN
|
||
|
|
RAISE EXCEPTION 'closed binding cannot be restored' USING ERRCODE='P0001';
|
||
|
|
END IF;
|
||
|
|
is_changed := OLD.unbound_at IS NULL AND NEW.unbound_at IS NOT NULL;
|
||
|
|
END IF;
|
||
|
|
IF is_changed THEN
|
||
|
|
UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=d.id;
|
||
|
|
UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=d.id AND status='PENDING'
|
||
|
|
AND id::text IS DISTINCT FROM current_setting('app.onboarding_claim',true);
|
||
|
|
IF TG_OP='UPDATE' THEN
|
||
|
|
UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='BINDING_CHANGED' WHERE device_id=d.id;
|
||
|
|
UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=d.id);
|
||
|
|
UPDATE device_activation_job SET config_ack_at=NULL,engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,
|
||
|
|
status=CASE WHEN status IN ('CONTACT_POSSIBLE','RECONCILE_REQUIRED') THEN 'RECONCILE_REQUIRED' ELSE 'BLOCKED' END,
|
||
|
|
last_error_code='BINDING_CHANGED',version=version+1,updated_at=now() WHERE device_id=d.id;
|
||
|
|
END IF;
|
||
|
|
END IF;
|
||
|
|
RETURN NEW;
|
||
|
|
END $$;
|
||
|
|
CREATE TRIGGER device_binding_generation_guard BEFORE INSERT OR UPDATE ON device_binding FOR EACH ROW EXECUTE FUNCTION maintain_device_binding_generation();
|
||
|
|
|
||
|
|
COMMIT;
|