Skip to main content

backbone_core/workflow/
schema.rs

1//! The tables a workflow engine keeps, as SQL.
2//!
3//! The schema generator writes the same statements into a module's migration; this function is the
4//! framework's own copy, for tests and for a service that creates the tables itself.
5
6/// The statuses an instance moves through.
7pub const STATUSES: &[&str] = &[
8    "running",
9    "waiting",
10    "completed",
11    "failed",
12    "expired",
13    "compensating",
14    "compensated",
15    "cancelled",
16];
17
18/// Whether `schema` is a plain lowercase identifier, safe to put in a statement.
19pub fn valid_schema(schema: &str) -> bool {
20    let mut chars = schema.chars();
21    matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c == '_')
22        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
23        && schema.len() <= 48
24}
25
26/// The statements that create `bb_workflow_instances` and `bb_workflow_steps` in `schema`, fence
27/// them by `company_id` with row-level security. The outbox and inbox tables the engine stages events
28/// in and dedups resumes with are `backbone_core::write_hooks::migrate_outbox`'s (a module that stages
29/// events runs it already). Run it as the table owner. Not idempotent for the policies' sake: run it
30/// once, as a migration does; [`drop_and_create_policies`] is for a test that re-creates them.
31///
32/// # Panics
33/// On a `schema` that is not a plain lowercase identifier.
34pub fn migration_sql(schema: &str) -> String {
35    assert!(
36        valid_schema(schema),
37        "`{schema}` is not a plain schema name"
38    );
39    let statuses = STATUSES
40        .iter()
41        .map(|s| format!("'{s}'"))
42        .collect::<Vec<_>>()
43        .join(", ");
44    format!(
45        r#"CREATE SCHEMA IF NOT EXISTS {schema};
46
47CREATE TABLE IF NOT EXISTS {schema}.bb_workflow_instances (
48    id               uuid PRIMARY KEY,
49    workflow         text NOT NULL,
50    version          int  NOT NULL,
51    company_id       uuid NOT NULL,
52    org_unit_id      uuid,
53    status           text NOT NULL CHECK (status IN ({statuses})),
54    current_step     text,
55    context          jsonb NOT NULL DEFAULT '{{}}'::jsonb,
56    trigger_event_id uuid NOT NULL,
57    correlation_id   text,
58    deadline_at      timestamptz,
59    wait_event       text,
60    wait_key         text,
61    wait_due_at      timestamptz,
62    attempts         int  NOT NULL DEFAULT 0,
63    last_error       text,
64    created_at       timestamptz NOT NULL DEFAULT now(),
65    updated_at       timestamptz NOT NULL DEFAULT now(),
66    CONSTRAINT uq_bb_workflow_instances_trigger UNIQUE (workflow, trigger_event_id)
67);
68
69CREATE INDEX IF NOT EXISTS idx_bb_workflow_instances_due
70    ON {schema}.bb_workflow_instances (wait_due_at)
71    WHERE status IN ('waiting', 'running') AND wait_due_at IS NOT NULL;
72CREATE INDEX IF NOT EXISTS idx_bb_workflow_instances_wait
73    ON {schema}.bb_workflow_instances (wait_event, wait_key)
74    WHERE status = 'waiting';
75CREATE INDEX IF NOT EXISTS idx_bb_workflow_instances_unfinished
76    ON {schema}.bb_workflow_instances (status, updated_at)
77    WHERE status IN ('running', 'compensating');
78
79CREATE TABLE IF NOT EXISTS {schema}.bb_workflow_steps (
80    instance_id uuid NOT NULL REFERENCES {schema}.bb_workflow_instances (id) ON DELETE CASCADE,
81    company_id  uuid NOT NULL,
82    org_unit_id uuid,
83    seq         int  NOT NULL,
84    step        text NOT NULL,
85    outcome     text NOT NULL,
86    error       text,
87    started_at  timestamptz NOT NULL DEFAULT now(),
88    finished_at timestamptz NOT NULL DEFAULT now(),
89    PRIMARY KEY (instance_id, seq)
90);
91
92ALTER TABLE {schema}.bb_workflow_instances ENABLE ROW LEVEL SECURITY;
93ALTER TABLE {schema}.bb_workflow_instances FORCE ROW LEVEL SECURITY;
94ALTER TABLE {schema}.bb_workflow_steps ENABLE ROW LEVEL SECURITY;
95ALTER TABLE {schema}.bb_workflow_steps FORCE ROW LEVEL SECURITY;
96{policies}"#,
97        policies = policies_sql(schema)
98    )
99}
100
101fn policies_sql(schema: &str) -> String {
102    // The sweep reads across companies to find what is due, with `app.workflow_sweep` set for that
103    // one read; it can only read: the check clause stays the company, so nothing is written
104    // outside a company's own scope.
105    format!(
106        r#"CREATE POLICY bb_workflow_instances_company_isolation ON {schema}.bb_workflow_instances FOR ALL
107    USING      (company_id = NULLIF(current_setting('app.company_id', true), '')::uuid
108                OR current_setting('app.workflow_sweep', true) = 'on')
109    WITH CHECK (company_id = NULLIF(current_setting('app.company_id', true), '')::uuid);
110CREATE POLICY bb_workflow_steps_company_isolation ON {schema}.bb_workflow_steps FOR ALL
111    USING      (company_id = NULLIF(current_setting('app.company_id', true), '')::uuid)
112    WITH CHECK (company_id = NULLIF(current_setting('app.company_id', true), '')::uuid);
113"#
114    )
115}
116
117/// The statements that fence both tables by org unit instead of by company (ADR-0029), for a module
118/// whose tables are org-scoped: the `<table>_org_unit_isolation` policy (the entitlement union, plus
119/// the read-only sweep clause) replaces the company policy, and a trigger fills a NULL `org_unit_id`
120/// from the acting unit. A row with no org unit is then refused loudly. The generated migration
121/// adds the kind guard over `organization.org_units` to this.
122///
123/// # Panics
124/// On a `schema` that is not a plain lowercase identifier.
125pub fn org_fence_sql(schema: &str) -> String {
126    assert!(
127        valid_schema(schema),
128        "`{schema}` is not a plain schema name"
129    );
130    let mut out = String::new();
131    for table in ["bb_workflow_instances", "bb_workflow_steps"] {
132        let sweep = if table == "bb_workflow_instances" {
133            "\n                OR current_setting('app.workflow_sweep', true) = 'on'"
134        } else {
135            ""
136        };
137        out.push_str(&format!(
138            r#"DROP POLICY IF EXISTS {table}_company_isolation ON {schema}.{table};
139DROP POLICY IF EXISTS {table}_org_unit_isolation ON {schema}.{table};
140CREATE POLICY {table}_org_unit_isolation ON {schema}.{table} FOR ALL
141    USING      (org_unit_id = ANY(string_to_array(current_setting('app.scope_unit_ids', true), ',')::uuid[]){sweep})
142    WITH CHECK (org_unit_id = ANY(string_to_array(current_setting('app.scope_unit_ids', true), ',')::uuid[]));
143CREATE OR REPLACE FUNCTION {schema}.{table}_org_unit_fill() RETURNS trigger AS $$
144BEGIN
145    IF NEW.org_unit_id IS NULL THEN
146        NEW.org_unit_id := nullif(current_setting('app.acting_unit_id', true), '')::uuid;
147    END IF;
148    RETURN NEW;
149END;
150$$ LANGUAGE plpgsql;
151DROP TRIGGER IF EXISTS {table}_org_unit_fill ON {schema}.{table};
152CREATE TRIGGER {table}_org_unit_fill BEFORE INSERT ON {schema}.{table}
153    FOR EACH ROW EXECUTE FUNCTION {schema}.{table}_org_unit_fill();
154"#
155        ));
156    }
157    out
158}
159
160/// The statements that replace both policies, for a test that creates the tables more than once.
161pub fn drop_and_create_policies(schema: &str) -> String {
162    assert!(
163        valid_schema(schema),
164        "`{schema}` is not a plain schema name"
165    );
166    format!(
167        "DROP POLICY IF EXISTS bb_workflow_instances_company_isolation ON {schema}.bb_workflow_instances;\n\
168         DROP POLICY IF EXISTS bb_workflow_steps_company_isolation ON {schema}.bb_workflow_steps;\n{}",
169        policies_sql(schema)
170    )
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn the_migration_names_the_tables_the_fence_and_the_dedup_table() {
179        let sql = migration_sql("hr");
180        for needle in [
181            "hr.bb_workflow_instances",
182            "hr.bb_workflow_steps",
183            "UNIQUE (workflow, trigger_event_id)",
184            "FORCE ROW LEVEL SECURITY",
185            "'compensating'",
186        ] {
187            assert!(sql.contains(needle), "{needle}");
188        }
189    }
190
191    #[test]
192    fn a_schema_that_is_not_a_plain_identifier_is_refused() {
193        assert!(valid_schema("selling_2"));
194        for bad in ["", "Hr", "hr; drop", "9x", "a-b"] {
195            assert!(!valid_schema(bad), "{bad}");
196        }
197    }
198}