Files
konstruct/migrations/versions/006_rbac_roles.py
Adolfo Delorenzo f710c9c5fe feat(04-rbac-01): DB migration 006 + RBAC ORM models + config fields
- Migration 006: adds role TEXT+CHECK column to portal_users, backfills
  is_admin -> platform_admin/customer_admin, drops is_admin
- Migration 006: creates user_tenant_roles table (UNIQUE user_id+tenant_id)
- Migration 006: creates portal_invitations table with token_hash, status, expires_at
- PortalUser: replaced is_admin (bool) with role (str, default customer_admin)
- Added UserRole enum (PLATFORM_ADMIN, CUSTOMER_ADMIN, CUSTOMER_OPERATOR)
- Added UserTenantRole ORM model with FK cascade deletes
- Added PortalInvitation ORM model with token_hash unique constraint
- Settings: added invite_secret, smtp_host, smtp_port, smtp_username,
  smtp_password, smtp_from_email fields
2026-03-24 13:49:16 -06:00

221 lines
7.4 KiB
Python

"""RBAC roles: add role column to portal_users, user_tenant_roles, portal_invitations
Revision ID: 006
Revises: 005
Create Date: 2026-03-24
This migration adds:
1. `role` column to `portal_users` (TEXT + CHECK constraint):
- Nullable initially, backfilled, then made NOT NULL
- CHECK: role IN ('platform_admin', 'customer_admin', 'customer_operator')
- Backfill: is_admin=TRUE -> 'platform_admin', else -> 'customer_admin'
- Drops `is_admin` column after backfill
2. `user_tenant_roles` table:
- Maps portal users to tenants with a role
- UNIQUE(user_id, tenant_id) — one role per user per tenant
- ON DELETE CASCADE for both FKs
3. `portal_invitations` table:
- Invitation records for invite-only onboarding flow
- token_hash: SHA-256 of the raw HMAC token (unique, for lookup)
- status: 'pending' | 'accepted' | 'revoked'
- expires_at: TIMESTAMPTZ, 48h from creation
Design notes:
- TEXT + CHECK constraint (not sa.Enum) per Phase 1 ADR — avoids DDL type conflicts in Alembic
- portal_users: not RLS-protected (auth before tenant context)
- user_tenant_roles and portal_invitations: not RLS-protected (RBAC layer handles isolation)
- konstruct_app granted appropriate permissions per table
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = "006"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# =========================================================================
# 1. Add role column to portal_users
# =========================================================================
# Step 1a: Add as nullable first (to allow backfill)
op.add_column(
"portal_users",
sa.Column("role", sa.Text, nullable=True),
)
# Step 1b: Backfill — is_admin=TRUE -> 'platform_admin', else -> 'customer_admin'
op.execute("""
UPDATE portal_users
SET role = CASE WHEN is_admin = TRUE THEN 'platform_admin' ELSE 'customer_admin' END
""")
# Step 1c: Make NOT NULL
op.alter_column("portal_users", "role", nullable=False)
# Step 1d: Add CHECK constraint (TEXT + CHECK pattern, not sa.Enum)
op.create_check_constraint(
"ck_portal_users_role",
"portal_users",
"role IN ('platform_admin', 'customer_admin', 'customer_operator')",
)
# Step 1e: Drop is_admin column
op.drop_column("portal_users", "is_admin")
# =========================================================================
# 2. user_tenant_roles table
# =========================================================================
op.create_table(
"user_tenant_roles",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"user_id",
UUID(as_uuid=True),
sa.ForeignKey("portal_users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"tenant_id",
UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
sa.Text,
nullable=False,
comment="customer_admin | customer_operator",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant_role"),
)
op.create_index("ix_user_tenant_roles_user", "user_tenant_roles", ["user_id"])
op.create_index("ix_user_tenant_roles_tenant", "user_tenant_roles", ["tenant_id"])
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON user_tenant_roles TO konstruct_app")
# =========================================================================
# 3. portal_invitations table
# =========================================================================
op.create_table(
"portal_invitations",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"email",
sa.String(255),
nullable=False,
),
sa.Column(
"name",
sa.String(255),
nullable=False,
),
sa.Column(
"tenant_id",
UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
sa.Text,
nullable=False,
comment="customer_admin | customer_operator",
),
sa.Column(
"invited_by",
UUID(as_uuid=True),
sa.ForeignKey("portal_users.id"),
nullable=True,
comment="ID of the user who created the invitation (NULL for system-generated)",
),
sa.Column(
"token_hash",
sa.String(255),
nullable=False,
unique=True,
comment="SHA-256 hex digest of the raw HMAC invite token",
),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
comment="pending | accepted | revoked",
),
sa.Column(
"expires_at",
sa.DateTime(timezone=True),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
)
op.create_index("ix_portal_invitations_tenant", "portal_invitations", ["tenant_id"])
op.create_index("ix_portal_invitations_email", "portal_invitations", ["email"])
op.create_index("ix_portal_invitations_token_hash", "portal_invitations", ["token_hash"], unique=True)
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON portal_invitations TO konstruct_app")
def downgrade() -> None:
# Remove portal_invitations
op.execute("REVOKE ALL ON portal_invitations FROM konstruct_app")
op.drop_index("ix_portal_invitations_token_hash", table_name="portal_invitations")
op.drop_index("ix_portal_invitations_email", table_name="portal_invitations")
op.drop_index("ix_portal_invitations_tenant", table_name="portal_invitations")
op.drop_table("portal_invitations")
# Remove user_tenant_roles
op.execute("REVOKE ALL ON user_tenant_roles FROM konstruct_app")
op.drop_index("ix_user_tenant_roles_tenant", table_name="user_tenant_roles")
op.drop_index("ix_user_tenant_roles_user", table_name="user_tenant_roles")
op.drop_table("user_tenant_roles")
# Restore is_admin column on portal_users
op.drop_constraint("ck_portal_users_role", "portal_users", type_="check")
op.add_column(
"portal_users",
sa.Column("is_admin", sa.Boolean, nullable=True),
)
op.execute("""
UPDATE portal_users
SET is_admin = CASE WHEN role = 'platform_admin' THEN TRUE ELSE FALSE END
""")
op.alter_column("portal_users", "is_admin", nullable=False)
op.drop_column("portal_users", "role")