Skip to content
2 changes: 1 addition & 1 deletion service/internal/fixtures/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ func (f *Fixtures) provision(ctx context.Context, t string, c []string, v [][]an
// Migration adds standard actions [create, read, update, delete] to the database
func (f *Fixtures) loadMigratedStandardActions(ctx context.Context) {
actions := make(map[string]string)
rows, err := f.db.Client.Query(ctx, "SELECT id, name FROM actions WHERE is_standard = TRUE", nil)
rows, err := f.db.Client.Query(ctx, "SELECT id, name FROM actions WHERE is_standard = TRUE AND namespace_id IS NULL", nil)
if err != nil {
slog.Error("could not get standard actions", slog.Any("error", err))
panic("could not get standard actions")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Add Namespace to Actions

This migration introduces namespace-scoped actions by adding `namespace_id` to `actions` and updating uniqueness semantics.

## Why

Policy objects are namespaced, and action references need to support same-namespace resolution. This migration enables actions to be scoped per namespace while preserving legacy/global actions.

## Up Migration

The Up migration:

- Adds nullable `namespace_id` to `actions` (`REFERENCES attribute_namespaces(id) ON DELETE CASCADE`).
- Replaces global `UNIQUE(name)` with two partial uniqueness constraints:
- `UNIQUE(namespace_id, name)` when `namespace_id IS NOT NULL` (namespaced actions)
- `UNIQUE(name)` when `namespace_id IS NULL` (legacy/global actions)
- Adds `idx_actions_namespace_id` for namespaced lookup performance.

## Down Migration

The Down migration is data-aware and performs a deterministic canonicalization before restoring global-only action semantics.

Steps:

1. Build an action-id remap (`old_action_id -> canonical_action_id`) by action name.
2. Canonical action selection order per name is:
- prefer global (`namespace_id IS NULL`)
- then earliest `created_at`
- then smallest `id`
3. Remap and deduplicate references in:
- `subject_mapping_actions`
- `registered_resource_action_attribute_values`
- `obligation_triggers`
4. Delete duplicate (non-canonical) action rows.
5. Drop namespace indexes, restore global `UNIQUE(name)`, and drop `namespace_id`.

## Rollback Semantics

Rollback is intentionally **lossy** with respect to namespace-level action identity.

- Actions sharing the same `name` are collapsed to one canonical global action row.
- Referencing rows are rewritten to canonical action ids.
- Distinct namespace-scoped action ids for the same name are not preserved after rollback.

This behavior is required to safely re-establish global `UNIQUE(name)` without orphaning references.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,128 @@ CREATE INDEX idx_actions_namespace_id
-- +goose Down
-- +goose StatementBegin

-- Roll back namespace-scoped actions to global actions by canonicalizing action IDs
-- by action name, remapping references, and deleting duplicate action rows.
-- Canonical action selection prefers legacy/global actions (namespace_id IS NULL),
-- then earliest created_at, then smallest id for deterministic behavior.

CREATE TEMP TABLE action_id_remap AS
WITH canonical AS (
SELECT
id,
name,
FIRST_VALUE(id) OVER (
PARTITION BY name
ORDER BY (namespace_id IS NULL) DESC, created_at ASC, id ASC
) AS canonical_id
FROM actions
)
SELECT id AS old_action_id, canonical_id AS new_action_id
FROM canonical
WHERE id <> canonical_id;
Comment thread
elizabethhealy marked this conversation as resolved.
Resolved

-- subject_mapping_actions references actions(id) and has PK(subject_mapping_id, action_id).
-- Rebuild table contents in deduplicated form after remapping action ids.
CREATE TEMP TABLE subject_mapping_actions_dedup AS
SELECT
sma.subject_mapping_id,
COALESCE(r.new_action_id, sma.action_id) AS action_id,
MIN(sma.created_at) AS created_at
FROM subject_mapping_actions sma
LEFT JOIN action_id_remap r ON r.old_action_id = sma.action_id
GROUP BY sma.subject_mapping_id, COALESCE(r.new_action_id, sma.action_id);

DELETE FROM subject_mapping_actions;
Comment thread
elizabethhealy marked this conversation as resolved.
Resolved

INSERT INTO subject_mapping_actions (subject_mapping_id, action_id, created_at)
SELECT subject_mapping_id, action_id, created_at
FROM subject_mapping_actions_dedup;

-- registered_resource_action_attribute_values references actions(id) and has
-- UNIQUE(registered_resource_value_id, action_id, attribute_value_id).
CREATE TEMP TABLE rr_aav_dedup AS
SELECT DISTINCT ON (
rr.registered_resource_value_id,
COALESCE(r.new_action_id, rr.action_id),
rr.attribute_value_id
)
rr.id,
rr.registered_resource_value_id,
COALESCE(r.new_action_id, rr.action_id) AS action_id,
rr.attribute_value_id,
rr.created_at,
rr.updated_at
FROM registered_resource_action_attribute_values rr
LEFT JOIN action_id_remap r ON r.old_action_id = rr.action_id
ORDER BY rr.registered_resource_value_id, COALESCE(r.new_action_id, rr.action_id), rr.attribute_value_id, rr.id;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

DELETE FROM registered_resource_action_attribute_values;
Comment thread
elizabethhealy marked this conversation as resolved.
Resolved

INSERT INTO registered_resource_action_attribute_values (
id,
registered_resource_value_id,
action_id,
attribute_value_id,
created_at,
updated_at
)
SELECT
id,
registered_resource_value_id,
action_id,
attribute_value_id,
created_at,
updated_at
FROM rr_aav_dedup;

-- obligation_triggers references actions(id) and has client-aware uniqueness.
CREATE TEMP TABLE obligation_triggers_dedup AS
SELECT DISTINCT ON (
ot.obligation_value_id,
COALESCE(r.new_action_id, ot.action_id),
ot.attribute_value_id,
ot.client_id
)
ot.id,
ot.obligation_value_id,
COALESCE(r.new_action_id, ot.action_id) AS action_id,
ot.attribute_value_id,
ot.client_id,
ot.metadata,
ot.created_at,
ot.updated_at
FROM obligation_triggers ot
LEFT JOIN action_id_remap r ON r.old_action_id = ot.action_id
ORDER BY ot.obligation_value_id, COALESCE(r.new_action_id, ot.action_id), ot.attribute_value_id, ot.client_id, ot.id;

DELETE FROM obligation_triggers;
Comment thread
elizabethhealy marked this conversation as resolved.
Resolved

INSERT INTO obligation_triggers (
id,
obligation_value_id,
action_id,
attribute_value_id,
client_id,
metadata,
created_at,
updated_at
)
SELECT
id,
obligation_value_id,
action_id,
attribute_value_id,
client_id,
metadata,
created_at,
updated_at
FROM obligation_triggers_dedup;

-- Remove duplicate actions after references are remapped.
DELETE FROM actions a
USING action_id_remap r
WHERE a.id = r.old_action_id;

DROP INDEX IF EXISTS idx_actions_namespace_id;
DROP INDEX IF EXISTS actions_name_unique;
DROP INDEX IF EXISTS actions_namespace_name_unique;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Seed Standard Actions for Existing Namespaces

This migration retroactively seeds the four standard actions (create, read, update, delete) into all namespaces that already exist.

## Why

When a namespace is created, `CreateNamespace()` automatically calls `seedStandardActionsForNamespace()` to insert the standard CRUD actions scoped to that namespace. However, namespaces created before action namespacing was introduced (`20260312000000_add_namespace_to_actions.sql`) do not have namespace-scoped standard actions — they only have the legacy global standard actions.

The otdfctl policy migration tooling requires that every namespace already has standard actions before migrating existing unnamespaced policy (SMs, SCSs, RRs) into namespaces. Without this seed, migrated policy referencing standard actions would have no same-namespace action to rewrite references to.

## Changes

A single idempotent `INSERT ... ON CONFLICT DO NOTHING` that cross-joins all existing namespaces with the four standard action names and inserts any missing rows into the `actions` table.

## Resulting Behavior

- Every existing namespace will have namespace-scoped standard actions (create, read, update, delete) with `is_standard = TRUE`.
- New namespaces continue to receive standard actions via `CreateNamespace()` as before.
- Running this migration on a system where some or all namespaces already have standard actions is safe — conflicts are silently ignored.

## Rollback

The Down migration is intentionally a no-op. Namespace-scoped standard actions are required for namespace correctness and policy reference rewrites; deleting them on rollback could break existing namespaces.

## Operational Rollback Note

Rolling back past `20260312000000_add_namespace_to_actions.sql` now performs an automatic action-id canonicalization and reference remap by action name before restoring global `UNIQUE(name)` semantics. This allows namespace-scoped duplicates (including standard actions) to be merged safely for rollback.

This rollback remains intentionally lossy with respect to namespace-level action identity: actions sharing the same name are collapsed to one global action id, and policy references are rewritten to that canonical action.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- +goose Up
-- +goose StatementBegin

-- Seed standard actions (create, read, update, delete) into all existing namespaces.
-- New namespaces already receive standard actions automatically via CreateNamespace().
-- This migration retroactively seeds them for namespaces created before action namespacing was introduced.
-- ON CONFLICT DO NOTHING makes this idempotent.
INSERT INTO actions (name, is_standard, namespace_id)
SELECT a.name, TRUE, ns.id
FROM attribute_namespaces ns
CROSS JOIN (VALUES ('create'), ('read'), ('update'), ('delete')) AS a(name)
ON CONFLICT (namespace_id, name) WHERE namespace_id IS NOT NULL DO NOTHING;

-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin

-- No-op: namespace-scoped standard actions are required for namespace policy
-- evaluation and reference rewrites. Removing them can break existing namespaces.
SELECT 1;

-- +goose StatementEnd
Loading