From 9fe412a78b55577ed8f4ed6d4fad4931e7150698 Mon Sep 17 00:00:00 2001 From: Aegis Stack Date: Sun, 15 Mar 2026 23:03:50 -0400 Subject: [PATCH] RBAC - 3 --- aegis/core/migration_generator.py | 1 + aegis/core/template_generator.py | 6 +- .../api/auth/{router.py => router.py.jinja} | 71 +++++++ .../core/{security.py => security.py.jinja} | 9 + .../app/models/{user.py => user.py.jinja} | 3 + .../app/services/auth/auth_service.py | 40 ---- .../app/services/auth/auth_service.py.jinja | 83 ++++++++ tests/core/test_auth_service_parser.py | 190 ++++++++++++++++++ tests/core/test_migration_generator.py | 2 + tests/core/test_template_generator.py | 130 +++++++++++- 10 files changed, 492 insertions(+), 43 deletions(-) rename aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/{router.py => router.py.jinja} (69%) rename aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/{security.py => security.py.jinja} (90%) rename aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/{user.py => user.py.jinja} (93%) delete mode 100644 aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py create mode 100644 aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja create mode 100644 tests/core/test_auth_service_parser.py diff --git a/aegis/core/migration_generator.py b/aegis/core/migration_generator.py index 800de266..bf962788 100644 --- a/aegis/core/migration_generator.py +++ b/aegis/core/migration_generator.py @@ -90,6 +90,7 @@ class ServiceMigrationSpec: ColumnSpec( "is_verified", "sa.Boolean()", nullable=False, default="False" ), + ColumnSpec("role", "sa.String()", nullable=False, default="'user'"), ColumnSpec("hashed_password", "sa.String()", nullable=False), ColumnSpec("last_login", "sa.DateTime()", nullable=True), ColumnSpec("created_at", "sa.DateTime()", nullable=False), diff --git a/aegis/core/template_generator.py b/aegis/core/template_generator.py index 5f2b445d..4221c6e1 100644 --- a/aegis/core/template_generator.py +++ b/aegis/core/template_generator.py @@ -119,11 +119,13 @@ def __init__( # Extract auth level from auth[level] format in services self.auth_level = AuthLevels.BASIC # Default to basic + self._user_specified_auth_level = False for service in self.selected_services: if extract_base_service_name(service) == SERVICE_AUTH: if is_auth_service_with_options(service): auth_config = parse_auth_service_config(service) self.auth_level = auth_config.level + self._user_specified_auth_level = True break # Auto-detect: if AI service selected AND database available AND no explicit backend, @@ -423,8 +425,8 @@ def _get_auth_level(self) -> str: if not has_auth: return AuthLevels.BASIC # Default - # If bracket syntax was parsed, self.auth_level is already set - if self.auth_level != AuthLevels.BASIC: + # If bracket syntax was used, trust the parsed value + if self._user_specified_auth_level: return self.auth_level # Fall back to interactive global selection diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py.jinja similarity index 69% rename from aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py rename to aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py.jinja index 097c0c35..659b05ac 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py.jinja @@ -10,6 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlmodel.ext.asyncio.session import AsyncSession +{% if include_auth_rbac %} +from app.models.user import User +from app.services.auth.auth_service import require_role +{% endif %} router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -57,7 +61,11 @@ async def login( ) # Create access token +{% if include_auth_rbac %} + access_token = create_access_token(data={"sub": user.email, "role": user.role}) +{% else %} access_token = create_access_token(data={"sub": user.email}) +{% endif %} return {"access_token": access_token, "token_type": "bearer"} @@ -71,6 +79,17 @@ async def get_current_user( return UserResponse.model_validate(user) +{% if include_auth_rbac %} +@router.get("/users", response_model=list[UserResponse]) +async def list_users( + current_user: User = Depends(require_role("admin")), + db: AsyncSession = Depends(get_async_db), +) -> list[UserResponse]: + """List all users. Requires admin role.""" + user_service = UserService(db) + users = await user_service.list_users() + return [UserResponse.model_validate(u) for u in users] +{% else %} @router.get("/users", response_model=list[UserResponse]) async def list_users( db: AsyncSession = Depends(get_async_db), @@ -79,6 +98,7 @@ async def list_users( user_service = UserService(db) users = await user_service.list_users() return [UserResponse.model_validate(user) for user in users] +{% endif %} @router.get("/users/{user_id}", response_model=UserResponse) @@ -125,6 +145,56 @@ async def update_user( return UserResponse.model_validate(user) +{% if include_auth_rbac %} +@router.patch("/users/{user_id}/deactivate", response_model=UserResponse) +async def deactivate_user( + user_id: int, + current_user: User = Depends(require_role("admin")), + db: AsyncSession = Depends(get_async_db), +) -> UserResponse: + """Deactivate a user account. Requires admin role.""" + user_service = UserService(db) + deactivated = await user_service.deactivate_user(user_id) + if not deactivated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return UserResponse.model_validate(deactivated) + + +@router.patch("/users/{user_id}/activate", response_model=UserResponse) +async def activate_user( + user_id: int, + current_user: User = Depends(require_role("admin")), + db: AsyncSession = Depends(get_async_db), +) -> UserResponse: + """Activate a user account. Requires admin role.""" + user_service = UserService(db) + activated = await user_service.activate_user(user_id) + if not activated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return UserResponse.model_validate(activated) + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + current_user: User = Depends(require_role("admin")), + db: AsyncSession = Depends(get_async_db), +) -> None: + """Permanently delete a user. Requires admin role.""" + user_service = UserService(db) + success = await user_service.delete_user(user_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) +{% else %} @router.patch("/users/{user_id}/deactivate", response_model=UserResponse) async def deactivate_user( user_id: int, @@ -170,3 +240,4 @@ async def delete_user( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", ) +{% endif %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/security.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/security.py.jinja similarity index 90% rename from aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/security.py rename to aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/security.py.jinja index 2c140ded..3830ada4 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/security.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/security.py.jinja @@ -57,3 +57,12 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify a password against its hash.""" truncated = _truncate_password(plain_password) return bcrypt.checkpw(truncated.encode("utf-8"), hashed_password.encode("utf-8")) + +{% if include_auth_rbac %} + +# Role constants +ROLE_ADMIN = "admin" +ROLE_MODERATOR = "moderator" +ROLE_USER = "user" +VALID_ROLES = {ROLE_ADMIN, ROLE_MODERATOR, ROLE_USER} +{% endif %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py.jinja similarity index 93% rename from aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py rename to aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py.jinja index 7da73876..af8198f2 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py.jinja @@ -13,6 +13,9 @@ class UserBase(SQLModel): full_name: str | None = None is_active: bool = Field(default=True) is_verified: bool = Field(default=False) +{% if include_auth_rbac %} + role: str = Field(default="user") +{% endif %} class User(UserBase, table=True): diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py deleted file mode 100644 index 08e9f9aa..00000000 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Authentication service utilities.""" - -from app.core.security import verify_token -from app.models.user import User -from app.services.auth.user_service import UserService -from fastapi import HTTPException, status -from sqlmodel.ext.asyncio.session import AsyncSession - - -async def get_current_user_from_token(token: str, db: AsyncSession) -> User: - """Get current user from JWT token.""" - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Verify token - payload = verify_token(token) - if payload is None: - raise credentials_exception - - # Get user email from token - email = payload.get("sub") - if not isinstance(email, str) or email is None: - raise credentials_exception - - # Get user from database - user_service = UserService(db) - user = await user_service.get_user_by_email(email) - if user is None: - raise credentials_exception - - # Check if user is active - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user" - ) - - return user diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja new file mode 100644 index 00000000..e17e3f98 --- /dev/null +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja @@ -0,0 +1,83 @@ +"""Authentication service utilities.""" + +from app.core.security import verify_token +from app.models.user import User +from app.services.auth.user_service import UserService +from fastapi import HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession +{% if include_auth_rbac %} +from fastapi import Depends +from fastapi.security import OAuth2PasswordBearer + +from app.components.backend.api.deps import get_async_db +from app.core.security import VALID_ROLES +{% endif %} + + +async def get_current_user_from_token(token: str, db: AsyncSession) -> User: + """Get current user from JWT token.""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Verify token + payload = verify_token(token) + if payload is None: + raise credentials_exception + + # Get user email from token + email = payload.get("sub") + if not isinstance(email, str) or email is None: + raise credentials_exception + + # Get user from database + user_service = UserService(db) + user = await user_service.get_user_by_email(email) + if user is None: + raise credentials_exception + + # Check if user is active + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user" + ) + + return user + +{% if include_auth_rbac %} + +_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token") + + +def require_role(*required_roles: str): + """FastAPI dependency that checks user has one of the required roles. + + Usage: + @router.get("/admin") + async def admin_endpoint(user: User = Depends(require_role("admin"))): + ... + + @router.get("/manage") + async def manage(user: User = Depends(require_role("admin", "moderator"))): + ... + """ + for role in required_roles: + if role not in VALID_ROLES: + raise ValueError(f"Invalid role: {role}. Valid roles: {VALID_ROLES}") + + async def dependency( + token: str = Depends(_oauth2_scheme), + db: AsyncSession = Depends(get_async_db), + ) -> User: + user = await get_current_user_from_token(token, db) + if user.role not in required_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions", + ) + return user + + return dependency +{% endif %} diff --git a/tests/core/test_auth_service_parser.py b/tests/core/test_auth_service_parser.py new file mode 100644 index 00000000..d0c2e33d --- /dev/null +++ b/tests/core/test_auth_service_parser.py @@ -0,0 +1,190 @@ +""" +Tests for auth service bracket syntax parser. + +Tests the parsing of auth[level] syntax where: +- Levels: basic, rbac + +Default (plain "auth" without brackets): basic +""" + +import pytest + +from aegis.core.auth_service_parser import ( + AuthServiceConfig, + is_auth_service_with_options, + parse_auth_service_config, +) + + +class TestAuthServiceParserDefaults: + """Test default values when no options specified.""" + + def test_bare_auth_returns_basic_default(self) -> None: + """auth → basic""" + result = parse_auth_service_config("auth") + assert result.level == "basic" + + def test_empty_brackets_returns_basic_default(self) -> None: + """auth[] → basic""" + result = parse_auth_service_config("auth[]") + assert result.level == "basic" + + +class TestAuthServiceParserLevels: + """Test with different auth levels specified.""" + + def test_basic_level_explicit(self) -> None: + """auth[basic] → basic""" + result = parse_auth_service_config("auth[basic]") + assert result.level == "basic" + + def test_rbac_level_explicit(self) -> None: + """auth[rbac] → rbac""" + result = parse_auth_service_config("auth[rbac]") + assert result.level == "rbac" + + def test_level_case_insensitive_rbac_uppercase(self) -> None: + """auth[RBAC] → rbac (case insensitive)""" + result = parse_auth_service_config("auth[RBAC]") + assert result.level == "rbac" + + def test_level_case_insensitive_rbac_mixed(self) -> None: + """auth[RbAc] → rbac (case insensitive)""" + result = parse_auth_service_config("auth[RbAc]") + assert result.level == "rbac" + + def test_level_case_insensitive_basic_uppercase(self) -> None: + """auth[BASIC] → basic (case insensitive)""" + result = parse_auth_service_config("auth[BASIC]") + assert result.level == "basic" + + +class TestAuthServiceParserWhitespace: + """Test whitespace handling.""" + + def test_leading_trailing_whitespace_bare(self) -> None: + """Whitespace around whole string handled (bare auth)""" + result = parse_auth_service_config(" auth ") + assert result.level == "basic" + + def test_leading_trailing_whitespace_with_brackets(self) -> None: + """Whitespace around whole string handled (with brackets)""" + result = parse_auth_service_config(" auth[rbac] ") + assert result.level == "rbac" + + def test_spaces_inside_brackets(self) -> None: + """auth[ rbac ] parses correctly with internal spaces""" + result = parse_auth_service_config("auth[ rbac ]") + assert result.level == "rbac" + + def test_tabs_inside_brackets(self) -> None: + """auth[\trbac\t] parses correctly with tabs""" + result = parse_auth_service_config("auth[\trbac\t]") + assert result.level == "rbac" + + +class TestAuthServiceParserErrors: + """Test error cases.""" + + def test_unknown_level_raises_error(self) -> None: + """Unknown level should raise ValueError with helpful message.""" + with pytest.raises(ValueError) as exc_info: + parse_auth_service_config("auth[invalid]") + error_msg = str(exc_info.value) + assert "Unknown auth level" in error_msg or "invalid" in error_msg + + def test_unknown_level_suggests_valid_options(self) -> None: + """Error message should suggest valid auth levels.""" + with pytest.raises(ValueError) as exc_info: + parse_auth_service_config("auth[unknown]") + error_msg = str(exc_info.value).lower() + assert "basic" in error_msg or "rbac" in error_msg + + def test_invalid_service_name_raises_error(self) -> None: + """Non-auth service should raise error.""" + with pytest.raises(ValueError) as exc_info: + parse_auth_service_config("comms[rbac]") + error_msg = str(exc_info.value).lower() + assert "auth" in error_msg + + def test_malformed_brackets_missing_closing(self) -> None: + """Malformed brackets should raise error.""" + with pytest.raises(ValueError): + parse_auth_service_config("auth[rbac") + + def test_malformed_brackets_missing_opening(self) -> None: + """Malformed brackets should raise error.""" + with pytest.raises(ValueError): + parse_auth_service_config("authrbac]") + + def test_multiple_values_raises_error(self) -> None: + """Multiple values in brackets should raise error.""" + with pytest.raises(ValueError): + parse_auth_service_config("auth[basic, rbac]") + + +class TestAuthServiceConfigDataclass: + """Test the AuthServiceConfig dataclass.""" + + def test_config_attributes(self) -> None: + """AuthServiceConfig has expected attributes.""" + config = AuthServiceConfig(level="rbac") + assert config.level == "rbac" + + def test_config_equality_same_values(self) -> None: + """AuthServiceConfig instances with same values are equal.""" + config1 = AuthServiceConfig(level="basic") + config2 = AuthServiceConfig(level="basic") + assert config1 == config2 + + def test_config_equality_different_values(self) -> None: + """AuthServiceConfig instances with different values are not equal.""" + config1 = AuthServiceConfig(level="basic") + config2 = AuthServiceConfig(level="rbac") + assert config1 != config2 + + +class TestIsAuthServiceWithOptions: + """Test the is_auth_service_with_options helper function. + + This function determines if bracket syntax is used, which affects + whether interactive selection or CLI parsing should be used. + """ + + def test_plain_auth_returns_false(self) -> None: + """Plain 'auth' without brackets should return False. + + When user specifies just 'auth', the system may prompt interactively + for level selection (or use default). + """ + assert is_auth_service_with_options("auth") is False + + def test_auth_with_empty_brackets_returns_true(self) -> None: + """auth[] should return True (explicit but empty options).""" + assert is_auth_service_with_options("auth[]") is True + + def test_auth_with_basic_returns_true(self) -> None: + """auth[basic] should return True.""" + assert is_auth_service_with_options("auth[basic]") is True + + def test_auth_with_rbac_returns_true(self) -> None: + """auth[rbac] should return True.""" + assert is_auth_service_with_options("auth[rbac]") is True + + def test_auth_with_spaces_returns_false(self) -> None: + """'auth' with surrounding spaces should return False.""" + assert is_auth_service_with_options(" auth ") is False + + def test_auth_bracket_with_spaces_returns_true(self) -> None: + """'auth[rbac]' with surrounding spaces should return True.""" + assert is_auth_service_with_options(" auth[rbac] ") is True + + def test_non_auth_service_returns_false(self) -> None: + """Non-auth services should return False.""" + assert is_auth_service_with_options("ai") is False + assert is_auth_service_with_options("comms") is False + + def test_partial_auth_name_returns_false(self) -> None: + """Partial matches like 'auth_test' should return False.""" + assert is_auth_service_with_options("auth_test") is False + assert is_auth_service_with_options("my_auth") is False diff --git a/tests/core/test_migration_generator.py b/tests/core/test_migration_generator.py index 65963ab9..72559480 100644 --- a/tests/core/test_migration_generator.py +++ b/tests/core/test_migration_generator.py @@ -204,6 +204,7 @@ def test_generates_auth_migration(self, tmp_path: Path) -> None: assert "'user'" in content assert "'email'" in content assert "'is_verified'" in content + assert "'role'" in content assert "'last_login'" in content def test_generates_ai_migration(self, tmp_path: Path) -> None: @@ -284,6 +285,7 @@ def test_auth_spec_exists(self) -> None: assert AUTH_MIGRATION.tables[0].name == "user" column_names = [col.name for col in AUTH_MIGRATION.tables[0].columns] assert "is_verified" in column_names + assert "role" in column_names assert "last_login" in column_names def test_ai_spec_exists(self) -> None: diff --git a/tests/core/test_template_generator.py b/tests/core/test_template_generator.py index dcb090df..6ea978bb 100644 --- a/tests/core/test_template_generator.py +++ b/tests/core/test_template_generator.py @@ -8,7 +8,7 @@ from pathlib import Path -from aegis.constants import StorageBackends +from aegis.constants import AuthLevels, StorageBackends from aegis.core.template_generator import TemplateGenerator @@ -328,3 +328,131 @@ def test_context_with_both_features(self) -> None: context = gen.get_template_context() assert context["ai_rag"] == "yes" assert context["ai_voice"] == "yes" + + +class TestTemplateGeneratorAuthLevel: + """Test auth service level configuration in template context. + + These tests verify that the auth_level and include_auth_rbac flags + are correctly set in the template context when auth service is selected + with different level specifications. + """ + + def setup_method(self) -> None: + """Clear auth level selection before each test.""" + from aegis.cli.interactive import clear_auth_level_selection + + clear_auth_level_selection() + + def test_auth_basic_default(self) -> None: + """Auth service should default to basic level.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth"], + ) + assert gen.auth_level == AuthLevels.BASIC + + def test_auth_rbac_bracket_syntax(self) -> None: + """Auth service with auth[rbac] syntax should set rbac level.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth[rbac]"], + ) + assert gen.auth_level == AuthLevels.RBAC + + def test_auth_basic_bracket_syntax(self) -> None: + """Auth service with auth[basic] syntax should set basic level.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth[basic]"], + ) + assert gen.auth_level == AuthLevels.BASIC + + def test_no_auth_defaults_to_basic(self) -> None: + """Without auth service, auth_level should default to basic.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=[], + ) + assert gen.auth_level == AuthLevels.BASIC + + def test_context_auth_level_basic(self) -> None: + """Template context should include auth_level as 'basic' when basic.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth"], + ) + context = gen.get_template_context() + assert context["auth_level"] == AuthLevels.BASIC + + def test_context_auth_basic_bracket_overrides_interactive_rbac(self) -> None: + """Explicit auth[basic] should override prior interactive RBAC selection.""" + from aegis.cli.interactive import set_auth_level_selection + + set_auth_level_selection(service_name="auth", level="rbac") + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth[basic]"], + ) + context = gen.get_template_context() + assert context["auth_level"] == "basic" + assert context["include_auth_rbac"] == "no" + + def test_context_auth_level_rbac(self) -> None: + """Template context should include auth_level as 'rbac' when rbac.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth[rbac]"], + ) + context = gen.get_template_context() + assert context["auth_level"] == AuthLevels.RBAC + + def test_context_include_auth_rbac_no(self) -> None: + """Template context should include_auth_rbac as 'no' when basic.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth"], + ) + context = gen.get_template_context() + assert context["include_auth_rbac"] == "no" + + def test_context_include_auth_rbac_yes(self) -> None: + """Template context should include_auth_rbac as 'yes' when rbac.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth[rbac]"], + ) + context = gen.get_template_context() + assert context["include_auth_rbac"] == "yes" + + def test_auth_with_other_services(self) -> None: + """Auth with other services should preserve auth level.""" + gen = TemplateGenerator( + project_name="test", + selected_components=["database"], + selected_services=["auth[rbac]", "ai"], + ) + assert gen.auth_level == AuthLevels.RBAC + context = gen.get_template_context() + assert context["include_auth_rbac"] == "yes" + assert context["include_ai"] == "yes" + + def test_auth_empty_brackets_defaults_to_basic(self) -> None: + """Auth service with auth[] (empty brackets) should default to basic.""" + gen = TemplateGenerator( + project_name="test", + selected_components=[], + selected_services=["auth[]"], + ) + assert gen.auth_level == AuthLevels.BASIC + context = gen.get_template_context() + assert context["include_auth_rbac"] == "no"