Add multi-tenancy with Teams feature
Implement team-based organization for projects with role-based access control: Backend: - Add teams and team_memberships database tables (migrations 009, 009b) - Add Team and TeamMembership ORM models with relationships - Implement TeamAuthorizationService for team-level access control - Add team CRUD, membership, and projects API endpoints - Update project creation to support team assignment Frontend: - Add TeamContext for managing team state with localStorage persistence - Add TeamSelector component for switching between teams - Add TeamsPage, TeamDashboardPage, TeamSettingsPage, TeamMembersPage - Add team API client functions - Update navigation with Teams link Security: - Team role hierarchy: owner > admin > member - Membership checked before system admin fallback - Self-modification prevention for role changes - Email visibility restricted to team admins/owners - Slug validation rejects consecutive hyphens Tests: - Unit tests for TeamAuthorizationService - Integration tests for all team API endpoints
This commit is contained in:
@@ -32,6 +32,7 @@ class Project(Base):
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
created_by = Column(String(255), nullable=False)
|
||||
team_id = Column(UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"))
|
||||
|
||||
packages = relationship(
|
||||
"Package", back_populates="project", cascade="all, delete-orphan"
|
||||
@@ -39,10 +40,12 @@ class Project(Base):
|
||||
permissions = relationship(
|
||||
"AccessPermission", back_populates="project", cascade="all, delete-orphan"
|
||||
)
|
||||
team = relationship("Team", back_populates="projects")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_projects_name", "name"),
|
||||
Index("idx_projects_created_by", "created_by"),
|
||||
Index("idx_projects_team_id", "team_id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -369,6 +372,9 @@ class User(Base):
|
||||
sessions = relationship(
|
||||
"Session", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
team_memberships = relationship(
|
||||
"TeamMembership", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_users_username", "username"),
|
||||
@@ -561,3 +567,73 @@ class ArtifactDependency(Base):
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Team(Base):
|
||||
"""Team for organizing projects and users."""
|
||||
|
||||
__tablename__ = "teams"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(255), nullable=False)
|
||||
slug = Column(String(255), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
created_by = Column(String(255), nullable=False)
|
||||
settings = Column(JSON, default=dict)
|
||||
|
||||
# Relationships
|
||||
memberships = relationship(
|
||||
"TeamMembership", back_populates="team", cascade="all, delete-orphan"
|
||||
)
|
||||
projects = relationship("Project", back_populates="team")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_teams_slug", "slug"),
|
||||
Index("idx_teams_created_by", "created_by"),
|
||||
Index("idx_teams_created_at", "created_at"),
|
||||
CheckConstraint(
|
||||
"slug ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' OR slug ~ '^[a-z0-9]$'",
|
||||
name="check_team_slug_format",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TeamMembership(Base):
|
||||
"""Maps users to teams with their roles."""
|
||||
|
||||
__tablename__ = "team_memberships"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
team_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
role = Column(String(20), nullable=False, default="member")
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
invited_by = Column(String(255))
|
||||
|
||||
# Relationships
|
||||
team = relationship("Team", back_populates="memberships")
|
||||
user = relationship("User", back_populates="team_memberships")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_team_memberships_team_id", "team_id"),
|
||||
Index("idx_team_memberships_user_id", "user_id"),
|
||||
Index("idx_team_memberships_role", "role"),
|
||||
Index("idx_team_memberships_team_role", "team_id", "role"),
|
||||
Index("idx_team_memberships_unique", "team_id", "user_id", unique=True),
|
||||
CheckConstraint(
|
||||
"role IN ('owner', 'admin', 'member')",
|
||||
name="check_team_role",
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user