2 Commits

Author SHA1 Message Date
Mondo Diaz
0a69910e8b Merge branch 'feature/multi-tenancy-teams' into 'main'
Add multi-tenancy with Teams feature

Closes #88, #89, #90, #91, #92, #93, #94, #95, #96, #97, #98, #99, #100, #101, #102, #103, and #104

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!48
2026-01-28 12:50:58 -06:00
Mondo Diaz
576791d19e Add multi-tenancy with Teams feature 2026-01-28 12:50:58 -06:00
3 changed files with 15 additions and 113 deletions

View File

@@ -11,7 +11,7 @@ from typing import Optional
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import User, Session as UserSession, APIKey, Team, TeamMembership from .models import User, Session as UserSession, APIKey
from .config import get_settings from .config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -363,8 +363,6 @@ def create_default_admin(db: Session) -> Optional[User]:
The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable. The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable.
If not set, defaults to 'changeme123' and requires password change on first login. If not set, defaults to 'changeme123' and requires password change on first login.
Also creates the "Global Admins" team and adds the admin user to it.
""" """
# Check if any users exist # Check if any users exist
user_count = db.query(User).count() user_count = db.query(User).count()
@@ -387,27 +385,6 @@ def create_default_admin(db: Session) -> Optional[User]:
must_change_password=must_change, must_change_password=must_change,
) )
# Create Global Admins team and add admin to it
global_admins_team = Team(
name="Global Admins",
slug="global-admins",
description="System administrators with full access",
created_by="admin",
)
db.add(global_admins_team)
db.flush()
membership = TeamMembership(
team_id=global_admins_team.id,
user_id=admin.id,
role="owner",
invited_by="admin",
)
db.add(membership)
db.commit()
logger.info("Created Global Admins team and added admin as owner")
if settings.admin_password: if settings.admin_password:
logger.info("Created default admin user with configured password") logger.info("Created default admin user with configured password")
else: else:

View File

@@ -285,73 +285,6 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
# Teams and multi-tenancy migration (009_teams.sql)
"""
-- Create teams table
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255) NOT NULL,
settings JSONB DEFAULT '{}'::jsonb,
CONSTRAINT check_team_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' OR slug ~ '^[a-z0-9]$')
);
""",
"""
-- Create team_memberships table
CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
invited_by VARCHAR(255),
CONSTRAINT unique_team_membership UNIQUE (team_id, user_id),
CONSTRAINT check_team_role CHECK (role IN ('owner', 'admin', 'member'))
);
""",
# Add team_id column to projects table
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'projects' AND column_name = 'team_id'
) THEN
ALTER TABLE projects ADD COLUMN team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
END IF;
END $$;
""",
# Create indexes for teams
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_slug') THEN
CREATE INDEX idx_teams_slug ON teams(slug);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_by') THEN
CREATE INDEX idx_teams_created_by ON teams(created_by);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_at') THEN
CREATE INDEX idx_teams_created_at ON teams(created_at);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_team_id') THEN
CREATE INDEX idx_team_memberships_team_id ON team_memberships(team_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_user_id') THEN
CREATE INDEX idx_team_memberships_user_id ON team_memberships(user_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_role') THEN
CREATE INDEX idx_team_memberships_role ON team_memberships(role);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_projects_team_id') THEN
CREATE INDEX idx_projects_team_id ON projects(team_id);
END IF;
END $$;
""",
] ]
with engine.connect() as conn: with engine.connect() as conn:

View File

@@ -10,7 +10,6 @@ class TestCreateDefaultAdmin:
def test_create_default_admin_with_env_password(self): def test_create_default_admin_with_env_password(self):
"""Test that ORCHARD_ADMIN_PASSWORD env var sets admin password.""" """Test that ORCHARD_ADMIN_PASSWORD env var sets admin password."""
from app.auth import create_default_admin, verify_password from app.auth import create_default_admin, verify_password
from app.models import User
# Create mock settings with custom password # Create mock settings with custom password
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -20,23 +19,20 @@ class TestCreateDefaultAdmin:
mock_db = MagicMock() mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users mock_db.query.return_value.count.return_value = 0 # No existing users
# Track all objects that get created # Track the user that gets created
created_objects = [] created_user = None
def capture_object(obj): def capture_user(user):
created_objects.append(obj) nonlocal created_user
created_user = user
mock_db.add.side_effect = capture_object mock_db.add.side_effect = capture_user
with patch("app.auth.get_settings", return_value=mock_settings): with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db) admin = create_default_admin(mock_db)
# Verify objects were created (user, team, membership) # Verify the user was created
assert mock_db.add.called assert mock_db.add.called
assert len(created_objects) >= 1
# Find the user object
created_user = next((obj for obj in created_objects if isinstance(obj, User)), None)
assert created_user is not None assert created_user is not None
assert created_user.username == "admin" assert created_user.username == "admin"
assert created_user.is_admin is True assert created_user.is_admin is True
@@ -48,7 +44,6 @@ class TestCreateDefaultAdmin:
def test_create_default_admin_with_default_password(self): def test_create_default_admin_with_default_password(self):
"""Test that default password 'changeme123' is used when env var not set.""" """Test that default password 'changeme123' is used when env var not set."""
from app.auth import create_default_admin, verify_password from app.auth import create_default_admin, verify_password
from app.models import User
# Create mock settings with empty password (default) # Create mock settings with empty password (default)
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -58,23 +53,20 @@ class TestCreateDefaultAdmin:
mock_db = MagicMock() mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users mock_db.query.return_value.count.return_value = 0 # No existing users
# Track all objects that get created # Track the user that gets created
created_objects = [] created_user = None
def capture_object(obj): def capture_user(user):
created_objects.append(obj) nonlocal created_user
created_user = user
mock_db.add.side_effect = capture_object mock_db.add.side_effect = capture_user
with patch("app.auth.get_settings", return_value=mock_settings): with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db) admin = create_default_admin(mock_db)
# Verify objects were created # Verify the user was created
assert mock_db.add.called assert mock_db.add.called
assert len(created_objects) >= 1
# Find the user object
created_user = next((obj for obj in created_objects if isinstance(obj, User)), None)
assert created_user is not None assert created_user is not None
assert created_user.username == "admin" assert created_user.username == "admin"
assert created_user.is_admin is True assert created_user.is_admin is True