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
This commit is contained in:
2026-03-24 13:49:16 -06:00
parent 2aecc5c787
commit f710c9c5fe
3 changed files with 377 additions and 7 deletions

View File

@@ -0,0 +1,220 @@
"""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")

View File

@@ -120,6 +120,34 @@ class Settings(BaseSettings):
default="insecure-dev-secret-change-in-production",
description="Secret key for signing JWT tokens",
)
invite_secret: str = Field(
default="insecure-invite-secret-change-in-production",
description="HMAC secret for signing invite tokens (separate from auth_secret)",
)
# -------------------------------------------------------------------------
# SMTP (for invitation emails)
# -------------------------------------------------------------------------
smtp_host: str = Field(
default="localhost",
description="SMTP server hostname",
)
smtp_port: int = Field(
default=587,
description="SMTP server port",
)
smtp_username: str = Field(
default="",
description="SMTP authentication username",
)
smtp_password: str = Field(
default="",
description="SMTP authentication password",
)
smtp_from_email: str = Field(
default="noreply@konstruct.dev",
description="From address for outbound emails",
)
# -------------------------------------------------------------------------
# Service URLs

View File

@@ -7,23 +7,38 @@ Passwords are stored as bcrypt hashes — never plaintext.
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from shared.models.tenant import Base
class UserRole(str, enum.Enum):
"""
Platform-level role enum for portal_users and user_tenant_roles.
PLATFORM_ADMIN: Full access to all tenants and platform settings.
CUSTOMER_ADMIN: Admin within their own tenant(s); manages agents, keys, billing.
CUSTOMER_OPERATOR: Read-only + send-message access within their tenant(s).
"""
PLATFORM_ADMIN = "platform_admin"
CUSTOMER_ADMIN = "customer_admin"
CUSTOMER_OPERATOR = "customer_operator"
class PortalUser(Base):
"""
An operator with access to the Konstruct admin portal.
RLS is NOT applied to this table — users are authenticated before
tenant context is established. Authorization is handled at the
application layer (is_admin flag + JWT claims).
application layer (role field + JWT claims).
"""
__tablename__ = "portal_users"
@@ -40,11 +55,11 @@ class PortalUser(Base):
comment="bcrypt hash — never store plaintext",
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(
Boolean,
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
default=False,
comment="True for platform-level admin; tenant managers use RBAC",
default="customer_admin",
comment="platform_admin | customer_admin | customer_operator",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -59,4 +74,111 @@ class PortalUser(Base):
)
def __repr__(self) -> str:
return f"<PortalUser id={self.id} email={self.email!r} is_admin={self.is_admin}>"
return f"<PortalUser id={self.id} email={self.email!r} role={self.role!r}>"
class UserTenantRole(Base):
"""
Maps a portal user to a tenant with a specific role.
A user can have at most one role per tenant (UNIQUE constraint).
platform_admin users typically have no rows here — they bypass
tenant membership checks entirely.
"""
__tablename__ = "user_tenant_roles"
__table_args__ = (
UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant_role"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("portal_users.id", ondelete="CASCADE"),
nullable=False,
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="customer_admin | customer_operator",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
def __repr__(self) -> str:
return f"<UserTenantRole user={self.user_id} tenant={self.tenant_id} role={self.role!r}>"
class PortalInvitation(Base):
"""
Invitation record for invite-only onboarding flow.
token_hash stores SHA-256(raw_token) for secure lookup without exposing
the raw token in the DB. The raw token is returned to the inviter in the
API response and sent via email.
"""
__tablename__ = "portal_invitations"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="customer_admin | customer_operator",
)
invited_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("portal_users.id"),
nullable=True,
comment="ID of the user who created the invitation",
)
token_hash: Mapped[str] = mapped_column(
String(255),
nullable=False,
unique=True,
comment="SHA-256 hex digest of the raw HMAC invite token",
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
comment="pending | accepted | revoked",
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
def __repr__(self) -> str:
return (
f"<PortalInvitation id={self.id} email={self.email!r} "
f"status={self.status!r} tenant={self.tenant_id}>"
)