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:
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user