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:
220
migrations/versions/006_rbac_roles.py
Normal file
220
migrations/versions/006_rbac_roles.py
Normal 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")
|
||||||
@@ -120,6 +120,34 @@ class Settings(BaseSettings):
|
|||||||
default="insecure-dev-secret-change-in-production",
|
default="insecure-dev-secret-change-in-production",
|
||||||
description="Secret key for signing JWT tokens",
|
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
|
# Service URLs
|
||||||
|
|||||||
@@ -7,23 +7,38 @@ Passwords are stored as bcrypt hashes — never plaintext.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from shared.models.tenant import Base
|
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):
|
class PortalUser(Base):
|
||||||
"""
|
"""
|
||||||
An operator with access to the Konstruct admin portal.
|
An operator with access to the Konstruct admin portal.
|
||||||
|
|
||||||
RLS is NOT applied to this table — users are authenticated before
|
RLS is NOT applied to this table — users are authenticated before
|
||||||
tenant context is established. Authorization is handled at the
|
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"
|
__tablename__ = "portal_users"
|
||||||
@@ -40,11 +55,11 @@ class PortalUser(Base):
|
|||||||
comment="bcrypt hash — never store plaintext",
|
comment="bcrypt hash — never store plaintext",
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
is_admin: Mapped[bool] = mapped_column(
|
role: Mapped[str] = mapped_column(
|
||||||
Boolean,
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=False,
|
default="customer_admin",
|
||||||
comment="True for platform-level admin; tenant managers use RBAC",
|
comment="platform_admin | customer_admin | customer_operator",
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
@@ -59,4 +74,111 @@ class PortalUser(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
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}>"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user