Compare commits

...

6 Commits

Author SHA1 Message Date
0e0e26e2f7 semantic update 2026-01-28 16:57:19 +03:00
18b42f8dd0 semantic protocol condense + script update 2026-01-28 15:49:39 +03:00
e7b31accd6 tested 2026-01-27 23:49:19 +03:00
d3c3a80ed2 Передаем на тест 2026-01-27 16:32:08 +03:00
cc244c2d86 tasks ready 2026-01-27 13:26:06 +03:00
d10c23e658 Обновил gitignore - убрал логи 2026-01-26 22:15:17 +03:00
81 changed files with 74724 additions and 81316 deletions

7
.gitignore vendored
View File

@@ -66,7 +66,6 @@ backend/mappings.db
backend/tasks.db
# Git Integration repositories
backend/git_repos/
backend/backend/git_repos
backend/logs
backend/auth.db
semantics/reports

View File

@@ -31,6 +31,7 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- Local Filesystem (for artifacts), Config (for storage path) (014-file-storage-ui)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend) (015-frontend-nav-redesign)
- N/A (UI reorganization and API integration) (015-frontend-nav-redesign)
- SQLite (`auth.db`) for Users, Roles, Permissions, and Mappings. (016-multi-user-auth)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -51,9 +52,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
- 015-frontend-nav-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend)
- 014-file-storage-ui: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend)
- 013-unify-frontend-css: Added Node.js 18+ (Frontend Build), Svelte 5.x + SvelteKit, Tailwind CSS, `date-fns` (existing)
<!-- MANUAL ADDITIONS START -->

View File

@@ -0,0 +1,4 @@
---
description: USE SEMANTIC
---
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке

View File

@@ -1,8 +1,8 @@
<!--
SYNC IMPACT REPORT
Version: 1.8.0 (Frontend Unification)
Version: 1.9.0 (Security & RBAC Mandate)
Changes:
- Added Principle VIII: Unified Frontend Experience (Mandating Design System & i18n).
- Added Principle IX: Security & Access Control (Mandating granular permissions for plugins).
Templates Status:
- .specify/templates/plan-template.md: ✅ Aligned.
- .specify/templates/spec-template.md: ✅ Aligned.
@@ -41,6 +41,11 @@ To ensure a consistent and accessible user experience, all frontend implementati
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited.
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited.
### IX. Security & Access Control
To support the Role-Based Access Control (RBAC) system, all functional components must define explicit permissions.
- **Granular Permissions**: Every Plugin MUST define a unique permission string (e.g., `plugin:name:execute`) required for its operation.
- **Registration**: These permissions MUST be registered in the system database during initialization or plugin loading to ensure they are available for role assignment in the Admin UI.
## File Structure Standards
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
- Python Module Headers (`.py`)
@@ -68,4 +73,4 @@ This Constitution establishes the "Semantic Code Generation Protocol" as the sup
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
**Version**: 1.8.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-26
**Version**: 1.9.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-27

BIN
backend/backend/auth.db Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -25,9 +25,13 @@ keyring==25.7.0
more-itertools==10.8.0
pycparser==2.23
pydantic==2.12.5
pydantic-settings
pydantic_core==2.41.5
python-multipart==0.0.21
PyYAML==6.0.3
passlib[bcrypt]
python-jose[cryptography]
PyJWT
RapidFuzz==3.14.3
referencing==0.37.0
requests==2.32.5
@@ -44,4 +48,6 @@ websockets==15.0.1
pandas
psycopg2-binary
openpyxl
GitPython==3.1.44
GitPython==3.1.44
itsdangerous
email-validator

View File

@@ -1,59 +1,118 @@
# [DEF:AuthModule:Module]
# @SEMANTICS: auth, authentication, adfs, oauth, middleware
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints.
# @LAYER: UI (API)
# @RELATION: Used by API routers to protect endpoints that require authentication.
# [DEF:backend.src.api.auth:Module]
#
# @SEMANTICS: api, auth, routes, login, logout
# @PURPOSE: Authentication API endpoints.
# @LAYER: API
# @RELATION: USES -> backend.src.services.auth_service.AuthService
# @RELATION: USES -> backend.src.core.database.get_auth_db
#
# @INVARIANT: All auth endpoints must return consistent error codes.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ..core.database import get_auth_db
from ..services.auth_service import AuthService
from ..schemas.auth import Token, User as UserSchema
from ..dependencies import get_current_user
from ..core.auth.oauth import oauth, is_adfs_configured
from ..core.auth.logger import log_security_event
from ..core.logger import belief_scope
import starlette.requests
# [/SECTION]
# Placeholder for ADFS configuration. In a real app, this would come from a secure source.
# Create an in-memory .env file
from io import StringIO
config_data = StringIO("""
ADFS_CLIENT_ID=your-client-id
ADFS_CLIENT_SECRET=your-client-secret
ADFS_SERVER_METADATA_URL=https://your-adfs-server/.well-known/openid-configuration
""")
config = Config(config_data)
oauth = OAuth(config)
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for authentication routes.
router = APIRouter(prefix="/api/auth", tags=["auth"])
# [/DEF:router:Variable]
oauth.register(
name='adfs',
server_metadata_url=config('ADFS_SERVER_METADATA_URL'),
client_kwargs={'scope': 'openid profile email'}
)
# [DEF:login_for_access_token:Function]
# @PURPOSE: Authenticates a user and returns a JWT access token.
# @PRE: form_data contains username and password.
# @POST: Returns a Token object on success.
# @THROW: HTTPException 401 if authentication fails.
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
# @PARAM: db (Session) - Auth database session.
# @RETURN: Token - The generated JWT token.
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_auth_db)
):
with belief_scope("api.auth.login"):
auth_service = AuthService(db)
user = auth_service.authenticate_user(form_data.username, form_data.password)
if not user:
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
return auth_service.create_session(user)
# [/DEF:login_for_access_token:Function]
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize",
tokenUrl="https://your-adfs-server/adfs/oauth2/token",
)
# [DEF:read_users_me:Function]
# @PURPOSE: Retrieves the profile of the currently authenticated user.
# @PRE: Valid JWT token provided.
# @POST: Returns the current user's data.
# @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RETURN: UserSchema - The current user profile.
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.me"):
return current_user
# [/DEF:read_users_me:Function]
# [DEF:get_current_user:Function]
# @PURPOSE: Dependency to get the current user from the ADFS token.
# @PARAM: token (str) - The OAuth2 bearer token.
# @PRE: token should be provided via Authorization header.
# @POST: Returns user details if authenticated, else raises 401.
# @RETURN: Dict[str, str] - User information.
async def get_current_user(token: str = Depends(oauth2_scheme)):
"""
Dependency to get the current user from the ADFS token.
This is a placeholder and needs to be fully implemented.
"""
# In a real implementation, you would:
# 1. Validate the token with ADFS.
# 2. Fetch user information.
# 3. Create a user object.
# For now, we'll just check if a token exists.
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# A real implementation would return a user object.
return {"placeholder_user": "user@example.com"}
# [/DEF:get_current_user:Function]
# [/DEF:AuthModule:Module]
# [DEF:logout:Function]
# @PURPOSE: Logs out the current user (placeholder for session revocation).
# @PRE: Valid JWT token provided.
# @POST: Returns success message.
@router.post("/logout")
async def logout(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.logout"):
log_security_event("LOGOUT", current_user.username)
# In a stateless JWT setup, client-side token deletion is primary.
# Server-side revocation (blacklisting) can be added here if needed.
return {"message": "Successfully logged out"}
# [/DEF:logout:Function]
# [DEF:login_adfs:Function]
# @PURPOSE: Initiates the ADFS OIDC login flow.
# @POST: Redirects the user to ADFS.
@router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request):
with belief_scope("api.auth.login_adfs"):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
)
redirect_uri = request.url_for('auth_callback_adfs')
return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
# [/DEF:login_adfs:Function]
# [DEF:auth_callback_adfs:Function]
# @PURPOSE: Handles the callback from ADFS after successful authentication.
# @POST: Provisions user JIT and returns session token.
@router.get("/callback/adfs", name="auth_callback_adfs")
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
with belief_scope("api.auth.callback_adfs"):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
)
token = await oauth.adfs.authorize_access_token(request)
user_info = token.get('userinfo')
if not user_info:
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS")
auth_service = AuthService(db)
user = auth_service.provision_adfs_user(user_info)
return auth_service.create_session(user)
# [/DEF:auth_callback_adfs:Function]
# [/DEF:backend.src.api.auth:Module]

View File

@@ -1 +1 @@
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin

View File

@@ -0,0 +1,310 @@
# [DEF:backend.src.api.routes.admin:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, admin, users, roles, permissions
# @PURPOSE: Admin API endpoints for user and role management.
# @LAYER: API
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
# @RELATION: USES -> backend.src.dependencies.has_permission
#
# @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope.
# [SECTION: IMPORTS]
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_auth_db
from ...core.auth.repository import AuthRepository
from ...core.auth.security import get_password_hash
from ...schemas.auth import (
User as UserSchema, UserCreate, UserUpdate,
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
ADGroupMappingSchema, ADGroupMappingCreate
)
from ...models.auth import User, Role, Permission, ADGroupMapping
from ...dependencies import has_permission, get_current_user
from ...core.logger import logger, belief_scope
# [/SECTION]
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for admin routes.
router = APIRouter(prefix="/api/admin", tags=["admin"])
# [/DEF:router:Variable]
# [DEF:list_users:Function]
# @PURPOSE: Lists all registered users.
# @PRE: Current user has 'Admin' role.
# @POST: Returns a list of UserSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[UserSchema] - List of users.
@router.get("/users", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "READ"))
):
with belief_scope("api.admin.list_users"):
users = db.query(User).all()
return users
# [/DEF:list_users:Function]
# [DEF:create_user:Function]
# @PURPOSE: Creates a new local user.
# @PRE: Current user has 'Admin' role.
# @POST: New user is created in the database.
# @PARAM: user_in (UserCreate) - New user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The created user.
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.create_user"):
repo = AuthRepository(db)
if repo.get_user_by_username(user_in.username):
raise HTTPException(status_code=400, detail="Username already exists")
new_user = User(
username=user_in.username,
email=user_in.email,
password_hash=get_password_hash(user_in.password),
auth_source="LOCAL",
is_active=user_in.is_active
)
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
new_user.roles.append(role)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
# [/DEF:create_user:Function]
# [DEF:update_user:Function]
# @PURPOSE: Updates an existing user.
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: str,
user_in: UserUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.update_user"):
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_in.email is not None:
user.email = user_in.email
if user_in.is_active is not None:
user.is_active = user_in.is_active
if user_in.password is not None:
user.password_hash = get_password_hash(user_in.password)
if user_in.roles is not None:
user.roles = []
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
user.roles.append(role)
db.commit()
db.refresh(user)
return user
# [/DEF:update_user:Function]
# [DEF:delete_user:Function]
# @PURPOSE: Deletes a user.
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.delete_user"):
logger.info(f"[DEBUG] Attempting to delete user context={{'user_id': '{user_id}'}}")
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
logger.warning(f"[DEBUG] User not found for deletion context={{'user_id': '{user_id}'}}")
raise HTTPException(status_code=404, detail="User not found")
logger.info(f"[DEBUG] Found user to delete context={{'username': '{user.username}'}}")
db.delete(user)
db.commit()
logger.info(f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}")
return None
# [/DEF:delete_user:Function]
# [DEF:list_roles:Function]
# @PURPOSE: Lists all available roles.
# @RETURN: List[RoleSchema] - List of roles.
# @RELATION: CALLS -> backend.src.models.auth.Role
@router.get("/roles", response_model=List[RoleSchema])
async def list_roles(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_roles"):
return db.query(Role).all()
# [/DEF:list_roles:Function]
# [DEF:create_role:Function]
# @PURPOSE: Creates a new system role with associated permissions.
# @PRE: Role name must be unique.
# @POST: New Role record is created in auth.db.
# @PARAM: role_in (RoleCreate) - New role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The created role.
# @SIDE_EFFECT: Commits new role and associations to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_permission_by_id
@router.post("/roles", response_model=RoleSchema, status_code=status.HTTP_201_CREATED)
async def create_role(
role_in: RoleCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.create_role"):
if db.query(Role).filter(Role.name == role_in.name).first():
raise HTTPException(status_code=400, detail="Role already exists")
new_role = Role(name=role_in.name, description=role_in.description)
repo = AuthRepository(db)
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
new_role.permissions.append(perm)
db.add(new_role)
db.commit()
db.refresh(new_role)
return new_role
# [/DEF:create_role:Function]
# [DEF:update_role:Function]
# @PURPOSE: Updates an existing role's metadata and permissions.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is updated in auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: role_in (RoleUpdate) - Updated role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The updated role.
# @SIDE_EFFECT: Commits updates to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.put("/roles/{role_id}", response_model=RoleSchema)
async def update_role(
role_id: str,
role_in: RoleUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.update_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
if role_in.name is not None:
role.name = role_in.name
if role_in.description is not None:
role.description = role_in.description
if role_in.permissions is not None:
role.permissions = []
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
role.permissions.append(perm)
db.commit()
db.refresh(role)
return role
# [/DEF:update_role:Function]
# [DEF:delete_role:Function]
# @PURPOSE: Removes a role from the system.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is removed from auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: db (Session) - Auth database session.
# @RETURN: None
# @SIDE_EFFECT: Deletes record from auth.db and commits.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.delete_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
db.delete(role)
db.commit()
return None
# [/DEF:delete_role:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available system permissions for assignment.
# @POST: Returns a list of all PermissionSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[PermissionSchema] - List of permissions.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.list_permissions
@router.get("/permissions", response_model=List[PermissionSchema])
async def list_permissions(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_permissions"):
repo = AuthRepository(db)
return repo.list_permissions()
# [/DEF:list_permissions:Function]
# [DEF:list_ad_mappings:Function]
# @PURPOSE: Lists all AD Group to Role mappings.
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
async def list_ad_mappings(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("api.admin.list_ad_mappings"):
return db.query(ADGroupMapping).all()
# [/DEF:list_ad_mappings:Function]
# [DEF:create_ad_mapping:Function]
# @PURPOSE: Creates a new AD Group mapping.
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
async def create_ad_mapping(
mapping_in: ADGroupMappingCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("api.admin.create_ad_mapping"):
new_mapping = ADGroupMapping(
ad_group=mapping_in.ad_group,
role_id=mapping_in.role_id
)
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return new_mapping
# [/DEF:create_ad_mapping:Function]
# [/DEF:backend.src.api.routes.admin:Module]

View File

@@ -11,7 +11,7 @@
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Optional
from ...dependencies import get_config_manager, get_scheduler_service
from ...dependencies import get_config_manager, get_scheduler_service, has_permission
from ...core.superset_client import SupersetClient
from pydantic import BaseModel, Field
from ...core.config_models import Environment as EnvModel
@@ -47,7 +47,10 @@ class DatabaseResponse(BaseModel):
# @POST: Returns a list of EnvironmentResponse objects.
# @RETURN: List[EnvironmentResponse]
@router.get("", response_model=List[EnvironmentResponse])
async def get_environments(config_manager=Depends(get_config_manager)):
async def get_environments(
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("environments", "READ"))
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
# Ensure envs is a list
@@ -77,7 +80,8 @@ async def update_environment_schedule(
id: str,
schedule: ScheduleSchema,
config_manager=Depends(get_config_manager),
scheduler_service=Depends(get_scheduler_service)
scheduler_service=Depends(get_scheduler_service),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_environment_schedule", f"id={id}"):
envs = config_manager.get_environments()
@@ -104,7 +108,11 @@ async def update_environment_schedule(
# @PARAM: id (str) - The environment ID.
# @RETURN: List[Dict] - List of databases.
@router.get("/{id}/databases")
async def get_environment_databases(id: str, config_manager=Depends(get_config_manager)):
async def get_environment_databases(
id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environment_databases", f"id={id}"):
envs = config_manager.get_environments()
env = next((e for e in envs if e.id == id), None)

View File

@@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
import typing
from src.dependencies import get_config_manager
from src.dependencies import get_config_manager, has_permission
from src.core.database import get_db
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
from src.api.routes.git_schemas import (
@@ -34,7 +34,10 @@ git_service = GitService()
# @POST: Returns a list of all GitServerConfig objects from the database.
# @RETURN: List[GitServerConfigSchema]
@router.get("/config", response_model=List[GitServerConfigSchema])
async def get_git_configs(db: Session = Depends(get_db)):
async def get_git_configs(
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_git_configs"):
return db.query(GitServerConfig).all()
# [/DEF:get_git_configs:Function]
@@ -46,7 +49,11 @@ async def get_git_configs(db: Session = Depends(get_db)):
# @PARAM: config (GitServerConfigCreate)
# @RETURN: GitServerConfigSchema
@router.post("/config", response_model=GitServerConfigSchema)
async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)):
async def create_git_config(
config: GitServerConfigCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("create_git_config"):
db_config = GitServerConfig(**config.dict())
db.add(db_config)
@@ -61,7 +68,11 @@ async def create_git_config(config: GitServerConfigCreate, db: Session = Depends
# @POST: The configuration record is removed from the database.
# @PARAM: config_id (str)
@router.delete("/config/{config_id}")
async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
async def delete_git_config(
config_id: str,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("delete_git_config"):
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not db_config:
@@ -78,7 +89,10 @@ async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
# @POST: Returns success if the connection is validated via GitService.
# @PARAM: config (GitServerConfigCreate)
@router.post("/config/test")
async def test_git_config(config: GitServerConfigCreate):
async def test_git_config(
config: GitServerConfigCreate,
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("test_git_config"):
success = await git_service.test_connection(config.provider, config.url, config.pat)
if success:
@@ -94,7 +108,12 @@ async def test_git_config(config: GitServerConfigCreate):
# @PARAM: dashboard_id (int)
# @PARAM: init_data (RepoInitRequest)
@router.post("/repositories/{dashboard_id}/init")
async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Session = Depends(get_db)):
async def init_repository(
dashboard_id: int,
init_data: RepoInitRequest,
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("init_repository"):
# 1. Get config
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
@@ -138,7 +157,10 @@ async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Ses
# @PARAM: dashboard_id (int)
# @RETURN: List[BranchSchema]
@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema])
async def get_branches(dashboard_id: int):
async def get_branches(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_branches"):
try:
return git_service.list_branches(dashboard_id)
@@ -153,7 +175,11 @@ async def get_branches(dashboard_id: int):
# @PARAM: dashboard_id (int)
# @PARAM: branch_data (BranchCreate)
@router.post("/repositories/{dashboard_id}/branches")
async def create_branch(dashboard_id: int, branch_data: BranchCreate):
async def create_branch(
dashboard_id: int,
branch_data: BranchCreate,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("create_branch"):
try:
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
@@ -169,7 +195,11 @@ async def create_branch(dashboard_id: int, branch_data: BranchCreate):
# @PARAM: dashboard_id (int)
# @PARAM: checkout_data (BranchCheckout)
@router.post("/repositories/{dashboard_id}/checkout")
async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
async def checkout_branch(
dashboard_id: int,
checkout_data: BranchCheckout,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("checkout_branch"):
try:
git_service.checkout_branch(dashboard_id, checkout_data.name)
@@ -185,7 +215,11 @@ async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
# @PARAM: dashboard_id (int)
# @PARAM: commit_data (CommitCreate)
@router.post("/repositories/{dashboard_id}/commit")
async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
async def commit_changes(
dashboard_id: int,
commit_data: CommitCreate,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("commit_changes"):
try:
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
@@ -200,7 +234,10 @@ async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
# @POST: Local commits are pushed to the remote repository.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/push")
async def push_changes(dashboard_id: int):
async def push_changes(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("push_changes"):
try:
git_service.push_changes(dashboard_id)
@@ -215,7 +252,10 @@ async def push_changes(dashboard_id: int):
# @POST: Remote changes are fetched and merged into the local branch.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/pull")
async def pull_changes(dashboard_id: int):
async def pull_changes(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("pull_changes"):
try:
git_service.pull_changes(dashboard_id)
@@ -231,7 +271,11 @@ async def pull_changes(dashboard_id: int):
# @PARAM: dashboard_id (int)
# @PARAM: source_env_id (Optional[str])
@router.post("/repositories/{dashboard_id}/sync")
async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] = None):
async def sync_dashboard(
dashboard_id: int,
source_env_id: typing.Optional[str] = None,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("sync_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
@@ -251,7 +295,10 @@ async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str]
# @POST: Returns a list of DeploymentEnvironmentSchema objects.
# @RETURN: List[DeploymentEnvironmentSchema]
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
async def get_environments(config_manager=Depends(get_config_manager)):
async def get_environments(
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("environments", "READ"))
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
return [
@@ -271,7 +318,11 @@ async def get_environments(config_manager=Depends(get_config_manager)):
# @PARAM: dashboard_id (int)
# @PARAM: deploy_data (DeployRequest)
@router.post("/repositories/{dashboard_id}/deploy")
async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
async def deploy_dashboard(
dashboard_id: int,
deploy_data: DeployRequest,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("deploy_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
@@ -293,7 +344,11 @@ async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
# @PARAM: limit (int)
# @RETURN: List[CommitSchema]
@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema])
async def get_history(dashboard_id: int, limit: int = 50):
async def get_history(
dashboard_id: int,
limit: int = 50,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_history"):
try:
return git_service.get_commit_history(dashboard_id, limit)
@@ -308,7 +363,10 @@ async def get_history(dashboard_id: int, limit: int = 50):
# @PARAM: dashboard_id (int)
# @RETURN: dict
@router.get("/repositories/{dashboard_id}/status")
async def get_repository_status(dashboard_id: int):
async def get_repository_status(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_repository_status"):
try:
return git_service.get_status(dashboard_id)
@@ -325,7 +383,12 @@ async def get_repository_status(dashboard_id: int):
# @PARAM: staged (bool)
# @RETURN: str
@router.get("/repositories/{dashboard_id}/diff")
async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None, staged: bool = False):
async def get_repository_diff(
dashboard_id: int,
file_path: Optional[str] = None,
staged: bool = False,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_repository_diff"):
try:
diff_text = git_service.get_diff(dashboard_id, file_path, staged)

View File

@@ -1,5 +1,6 @@
# [DEF:backend.src.api.routes.git_schemas:Module]
#
# @TIER: STANDARD
# @SEMANTICS: git, schemas, pydantic, api, contracts
# @PURPOSE: Defines Pydantic models for the Git integration API layer.
# @LAYER: API
@@ -14,6 +15,7 @@ from uuid import UUID
from src.models.git import GitProvider, GitStatus, SyncStatus
# [DEF:GitServerConfigBase:Class]
# @TIER: TRIVIAL
# @PURPOSE: Base schema for Git server configuration attributes.
class GitServerConfigBase(BaseModel):
name: str = Field(..., description="Display name for the Git server")

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from ...core.logger import belief_scope
from ...dependencies import get_config_manager
from ...dependencies import get_config_manager, has_permission
from ...core.database import get_db
from ...models.mapping import DatabaseMapping
from pydantic import BaseModel
@@ -60,7 +60,8 @@ class SuggestRequest(BaseModel):
async def get_mappings(
source_env_id: Optional[str] = None,
target_env_id: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("get_mappings"):
query = db.query(DatabaseMapping)
@@ -76,7 +77,11 @@ async def get_mappings(
# @PRE: mapping is valid MappingCreate, db session is injected.
# @POST: DatabaseMapping created or updated in database.
@router.post("", response_model=MappingResponse)
async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
async def create_mapping(
mapping: MappingCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("create_mapping"):
# Check if mapping already exists
existing = db.query(DatabaseMapping).filter(
@@ -106,10 +111,11 @@ async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
@router.post("/suggest")
async def suggest_mappings_api(
request: SuggestRequest,
config_manager=Depends(get_config_manager)
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("suggest_mappings_api"):
from backend.src.services.mapping_service import MappingService
from ...services.mapping_service import MappingService
service = MappingService(config_manager)
try:
return await service.get_suggestions(request.source_env_id, request.target_env_id)

View File

@@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict
from ...dependencies import get_config_manager, get_task_manager
from ...dependencies import get_config_manager, get_task_manager, has_permission
from ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient
from ...core.logger import belief_scope
@@ -21,7 +21,11 @@ router = APIRouter(prefix="/api", tags=["migration"])
# @PARAM: env_id (str) - The ID of the environment to fetch from.
# @RETURN: List[DashboardMetadata]
@router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata])
async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)):
async def get_dashboards(
env_id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("get_dashboards", f"env_id={env_id}"):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
@@ -40,7 +44,12 @@ async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
# @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/migration/execute")
async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)):
async def execute_migration(
selection: DashboardSelection,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("execute_migration"):
# Validate environments exist
environments = config_manager.get_environments()

View File

@@ -7,7 +7,7 @@ from typing import List
from fastapi import APIRouter, Depends
from ...core.plugin_base import PluginConfig
from ...dependencies import get_plugin_loader
from ...dependencies import get_plugin_loader, has_permission
from ...core.logger import belief_scope
router = APIRouter()
@@ -19,7 +19,8 @@ router = APIRouter()
# @RETURN: List[PluginConfig] - List of registered plugins.
@router.get("", response_model=List[PluginConfig])
async def list_plugins(
plugin_loader = Depends(get_plugin_loader)
plugin_loader = Depends(get_plugin_loader),
_ = Depends(has_permission("plugins", "READ"))
):
with belief_scope("list_plugins"):
"""

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
from typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager
from ...dependencies import get_config_manager, has_permission
from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
@@ -29,7 +29,10 @@ router = APIRouter()
# @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration.
@router.get("", response_model=AppConfig)
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True)
@@ -49,7 +52,8 @@ async def get_settings(config_manager: ConfigManager = Depends(get_config_manage
@router.patch("/global", response_model=GlobalSettings)
async def update_global_settings(
settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating global settings")
@@ -62,7 +66,10 @@ async def update_global_settings(
# @PURPOSE: Retrieves storage-specific settings.
# @RETURN: StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig)
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_storage_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage
# [/DEF:get_storage_settings:Function]
@@ -73,7 +80,11 @@ async def get_storage_settings(config_manager: ConfigManager = Depends(get_confi
# @POST: Storage settings are updated and saved.
# @RETURN: StorageConfig - The updated storage settings.
@router.put("/storage", response_model=StorageConfig)
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
async def update_storage_settings(
storage: StorageConfig,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path)
if not is_valid:
@@ -91,7 +102,10 @@ async def update_storage_settings(storage: StorageConfig, config_manager: Config
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment])
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
return config_manager.get_environments()
@@ -106,7 +120,8 @@ async def get_environments(config_manager: ConfigManager = Depends(get_config_ma
@router.post("/environments", response_model=Environment)
async def add_environment(
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")

View File

@@ -8,11 +8,12 @@
# @INVARIANT: All paths must be validated against path traversal.
# [SECTION: IMPORTS]
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse
from typing import List, Optional
from ...models.storage import StoredFile, FileCategory
from ...dependencies import get_plugin_loader
from ...dependencies import get_plugin_loader, has_permission
from ...plugins.storage.plugin import StoragePlugin
from ...core.logger import belief_scope
# [/SECTION]
@@ -34,7 +35,8 @@ router = APIRouter(tags=["storage"])
async def list_files(
category: Optional[FileCategory] = None,
path: Optional[str] = None,
plugin_loader=Depends(get_plugin_loader)
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "READ"))
):
with belief_scope("list_files"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
@@ -63,7 +65,8 @@ async def upload_file(
category: FileCategory = Form(...),
path: Optional[str] = Form(None),
file: UploadFile = File(...),
plugin_loader=Depends(get_plugin_loader)
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "WRITE"))
):
with belief_scope("upload_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
@@ -89,7 +92,12 @@ async def upload_file(
#
# @RELATION: CALLS -> StoragePlugin.delete_file
@router.delete("/files/{category}/{path:path}", status_code=204)
async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)):
async def delete_file(
category: FileCategory,
path: str,
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "WRITE"))
):
with belief_scope("delete_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
@@ -114,7 +122,12 @@ async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(g
#
# @RELATION: CALLS -> StoragePlugin.get_file_path
@router.get("/download/{category}/{path:path}")
async def download_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)):
async def download_file(
category: FileCategory,
path: str,
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "READ"))
):
with belief_scope("download_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...dependencies import get_task_manager
from ...dependencies import get_task_manager, has_permission
router = APIRouter()
@@ -33,7 +33,8 @@ class ResumeTaskRequest(BaseModel):
# @RETURN: Task - The created task instance.
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(lambda req: has_permission(f"plugin:{req.plugin_id}", "EXECUTE"))
):
"""
Create and start a new task for a given plugin.
@@ -63,7 +64,8 @@ async def list_tasks(
limit: int = 10,
offset: int = 0,
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve a list of tasks with pagination and optional status filter.
@@ -82,7 +84,8 @@ async def list_tasks(
# @RETURN: Task - The task details.
async def get_task(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve the details of a specific task.
@@ -104,7 +107,8 @@ async def get_task(
# @RETURN: List[LogEntry] - List of log entries.
async def get_task_logs(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve logs for a specific task.
@@ -128,7 +132,8 @@ async def get_task_logs(
async def resolve_task(
task_id: str,
request: ResolveTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Resolve a task that is awaiting mapping.
@@ -153,7 +158,8 @@ async def resolve_task(
async def resume_task(
task_id: str,
request: ResumeTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Resume a task that is awaiting input (e.g., passwords).
@@ -175,7 +181,8 @@ async def resume_task(
# @POST: Tasks are removed from memory/persistence.
async def clear_tasks(
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Clear tasks matching the status filter. If no filter, clears all non-running tasks.

View File

@@ -10,6 +10,7 @@ from pathlib import Path
project_root = Path(__file__).resolve().parent.parent.parent
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@@ -18,7 +19,8 @@ import os
from .dependencies import get_task_manager, get_scheduler_service
from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin
from .api import auth
from .core.database import init_db
# [DEF:App:Global]
@@ -55,6 +57,10 @@ async def shutdown_event():
scheduler.stop()
# [/DEF:shutdown_event:Function]
# Configure Session Middleware (required by Authlib for OAuth2 flow)
from .core.auth.config import auth_config
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
# Configure CORS
app.add_middleware(
CORSMiddleware,
@@ -81,6 +87,8 @@ async def log_requests(request: Request, call_next):
# [/DEF:log_requests:Function]
# Include API routes
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])

View File

@@ -0,0 +1,45 @@
# [DEF:backend.src.core.auth.config:Module]
#
# @SEMANTICS: auth, config, settings, jwt, adfs
# @PURPOSE: Centralized configuration for authentication and authorization.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> pydantic
#
# @INVARIANT: All sensitive configuration must have defaults or be loaded from environment.
# [SECTION: IMPORTS]
from pydantic import Field
from pydantic_settings import BaseSettings
import os
# [/SECTION]
# [DEF:AuthConfig:Class]
# @PURPOSE: Holds authentication-related settings.
# @PRE: Environment variables may be provided via .env file.
# @POST: Returns a configuration object with validated settings.
class AuthConfig(BaseSettings):
# JWT Settings
SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database Settings
AUTH_DATABASE_URL: str = Field(default="sqlite:///./backend/auth.db", env="AUTH_DATABASE_URL")
# ADFS Settings
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
ADFS_CLIENT_SECRET: str = Field(default="", env="ADFS_CLIENT_SECRET")
ADFS_METADATA_URL: str = Field(default="", env="ADFS_METADATA_URL")
class Config:
env_file = ".env"
extra = "ignore"
# [/DEF:AuthConfig:Class]
# [DEF:auth_config:Variable]
# @PURPOSE: Singleton instance of AuthConfig.
auth_config = AuthConfig()
# [/DEF:auth_config:Variable]
# [/DEF:backend.src.core.auth.config:Module]

View File

@@ -0,0 +1,54 @@
# [DEF:backend.src.core.auth.jwt:Module]
#
# @SEMANTICS: jwt, token, session, auth
# @PURPOSE: JWT token generation and validation logic.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> jose
# @RELATION: USES -> backend.src.core.auth.config.auth_config
#
# @INVARIANT: Tokens must include expiration time and user identifier.
# [SECTION: IMPORTS]
from datetime import datetime, timedelta
from typing import Optional, List
from jose import JWTError, jwt
from .config import auth_config
from ..logger import belief_scope
# [/SECTION]
# [DEF:create_access_token:Function]
# @PURPOSE: Generates a new JWT access token.
# @PRE: data dict contains 'sub' (user_id) and optional 'scopes' (roles).
# @POST: Returns a signed JWT string.
#
# @PARAM: data (dict) - Payload data for the token.
# @PARAM: expires_delta (Optional[timedelta]) - Custom expiration time.
# @RETURN: str - The encoded JWT.
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
with belief_scope("create_access_token"):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=auth_config.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM)
return encoded_jwt
# [/DEF:create_access_token:Function]
# [DEF:decode_token:Function]
# @PURPOSE: Decodes and validates a JWT token.
# @PRE: token is a signed JWT string.
# @POST: Returns the decoded payload if valid.
#
# @PARAM: token (str) - The JWT to decode.
# @RETURN: dict - The decoded payload.
# @THROW: jose.JWTError - If token is invalid or expired.
def decode_token(token: str) -> dict:
with belief_scope("decode_token"):
payload = jwt.decode(token, auth_config.SECRET_KEY, algorithms=[auth_config.ALGORITHM])
return payload
# [/DEF:decode_token:Function]
# [/DEF:backend.src.core.auth.jwt:Module]

View File

@@ -0,0 +1,31 @@
# [DEF:backend.src.core.auth.logger:Module]
#
# @SEMANTICS: auth, logger, audit, security
# @PURPOSE: Audit logging for security-related events.
# @LAYER: Core
# @RELATION: USES -> backend.src.core.logger.belief_scope
#
# @INVARIANT: Must not log sensitive data like passwords or full tokens.
# [SECTION: IMPORTS]
from ..logger import logger, belief_scope
from datetime import datetime
# [/SECTION]
# [DEF:log_security_event:Function]
# @PURPOSE: Logs a security-related event for audit trails.
# @PRE: event_type and username are strings.
# @POST: Security event is written to the application log.
# @PARAM: event_type (str) - Type of event (e.g., LOGIN_SUCCESS, PERMISSION_DENIED).
# @PARAM: username (str) - The user involved in the event.
# @PARAM: details (dict) - Additional non-sensitive metadata.
def log_security_event(event_type: str, username: str, details: dict = None):
with belief_scope("log_security_event", f"{event_type}:{username}"):
timestamp = datetime.utcnow().isoformat()
msg = f"[AUDIT][{timestamp}][{event_type}] User: {username}"
if details:
msg += f" Details: {details}"
logger.info(msg)
# [/DEF:log_security_event:Function]
# [/DEF:backend.src.core.auth.logger:Module]

View File

@@ -0,0 +1,51 @@
# [DEF:backend.src.core.auth.oauth:Module]
#
# @SEMANTICS: auth, oauth, oidc, adfs
# @PURPOSE: ADFS OIDC configuration and client using Authlib.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> authlib
# @RELATION: USES -> backend.src.core.auth.config.auth_config
#
# @INVARIANT: Must use secure OIDC flows.
# [SECTION: IMPORTS]
from authlib.integrations.starlette_client import OAuth
from .config import auth_config
# [/SECTION]
# [DEF:oauth:Variable]
# @PURPOSE: Global Authlib OAuth registry.
oauth = OAuth()
# [/DEF:oauth:Variable]
# [DEF:register_adfs:Function]
# @PURPOSE: Registers the ADFS OIDC client.
# @PRE: ADFS configuration is provided in auth_config.
# @POST: ADFS client is registered in oauth registry.
def register_adfs():
if auth_config.ADFS_CLIENT_ID:
oauth.register(
name='adfs',
client_id=auth_config.ADFS_CLIENT_ID,
client_secret=auth_config.ADFS_CLIENT_SECRET,
server_metadata_url=auth_config.ADFS_METADATA_URL,
client_kwargs={
'scope': 'openid email profile groups'
}
)
# [/DEF:register_adfs:Function]
# [DEF:is_adfs_configured:Function]
# @PURPOSE: Checks if ADFS is properly configured.
# @PRE: None.
# @POST: Returns True if ADFS client is registered, False otherwise.
# @RETURN: bool - Configuration status.
def is_adfs_configured() -> bool:
"""Check if ADFS OAuth client is registered."""
return 'adfs' in oauth._registry
# [/DEF:is_adfs_configured:Function]
# Initial registration
register_adfs()
# [/DEF:backend.src.core.auth.oauth:Module]

View File

@@ -0,0 +1,123 @@
# [DEF:backend.src.core.auth.repository:Module]
#
# @SEMANTICS: auth, repository, database, user, role
# @PURPOSE: Data access layer for authentication-related entities.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.auth
#
# @INVARIANT: All database operations must be performed within a session.
# [SECTION: IMPORTS]
from typing import Optional, List
from sqlalchemy.orm import Session
from ...models.auth import User, Role, Permission, ADGroupMapping
from ..logger import belief_scope
# [/SECTION]
# [DEF:AuthRepository:Class]
# @PURPOSE: Encapsulates database operations for authentication.
class AuthRepository:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the repository with a database session.
# @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session):
self.db = db
# [/DEF:__init__:Function]
# [DEF:get_user_by_username:Function]
# @PURPOSE: Retrieves a user by their username.
# @PRE: username is a string.
# @POST: Returns User object if found, else None.
# @PARAM: username (str) - The username to search for.
# @RETURN: Optional[User] - The found user or None.
def get_user_by_username(self, username: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_username"):
return self.db.query(User).filter(User.username == username).first()
# [/DEF:get_user_by_username:Function]
# [DEF:get_user_by_id:Function]
# @PURPOSE: Retrieves a user by their unique ID.
# @PRE: user_id is a valid UUID string.
# @POST: Returns User object if found, else None.
# @PARAM: user_id (str) - The user's unique identifier.
# @RETURN: Optional[User] - The found user or None.
def get_user_by_id(self, user_id: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_id"):
return self.db.query(User).filter(User.id == user_id).first()
# [/DEF:get_user_by_id:Function]
# [DEF:get_role_by_name:Function]
# @PURPOSE: Retrieves a role by its name.
# @PRE: name is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: name (str) - The role name to search for.
# @RETURN: Optional[Role] - The found role or None.
def get_role_by_name(self, name: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_name"):
return self.db.query(Role).filter(Role.name == name).first()
# [/DEF:get_role_by_name:Function]
# [DEF:update_last_login:Function]
# @PURPOSE: Updates the last_login timestamp for a user.
# @PRE: user object is a valid User instance.
# @POST: User's last_login is updated in the database.
# @SIDE_EFFECT: Commits the transaction.
# @PARAM: user (User) - The user to update.
def update_last_login(self, user: User):
with belief_scope("AuthRepository.update_last_login"):
from datetime import datetime
user.last_login = datetime.utcnow()
self.db.add(user)
self.db.commit()
# [/DEF:update_last_login:Function]
# [DEF:get_role_by_id:Function]
# @PURPOSE: Retrieves a role by its unique ID.
# @PRE: role_id is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: role_id (str) - The role's unique identifier.
# @RETURN: Optional[Role] - The found role or None.
def get_role_by_id(self, role_id: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_id"):
return self.db.query(Role).filter(Role.id == role_id).first()
# [/DEF:get_role_by_id:Function]
# [DEF:get_permission_by_id:Function]
# @PURPOSE: Retrieves a permission by its unique ID.
# @PRE: perm_id is a string.
# @POST: Returns Permission object if found, else None.
# @PARAM: perm_id (str) - The permission's unique identifier.
# @RETURN: Optional[Permission] - The found permission or None.
def get_permission_by_id(self, perm_id: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_id"):
return self.db.query(Permission).filter(Permission.id == perm_id).first()
# [/DEF:get_permission_by_id:Function]
# [DEF:get_permission_by_resource_action:Function]
# @PURPOSE: Retrieves a permission by resource and action.
# @PRE: resource and action are strings.
# @POST: Returns Permission object if found, else None.
# @PARAM: resource (str) - The resource name.
# @PARAM: action (str) - The action name.
# @RETURN: Optional[Permission] - The found permission or None.
def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_resource_action"):
return self.db.query(Permission).filter(
Permission.resource == resource,
Permission.action == action
).first()
# [/DEF:get_permission_by_resource_action:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available permissions.
# @POST: Returns a list of all Permission objects.
# @RETURN: List[Permission] - List of permissions.
def list_permissions(self) -> List[Permission]:
with belief_scope("AuthRepository.list_permissions"):
return self.db.query(Permission).all()
# [/DEF:list_permissions:Function]
# [/DEF:AuthRepository:Class]
# [/DEF:backend.src.core.auth.repository:Module]

View File

@@ -0,0 +1,42 @@
# [DEF:backend.src.core.auth.security:Module]
#
# @SEMANTICS: security, password, hashing, bcrypt
# @PURPOSE: Utility for password hashing and verification using Passlib.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> passlib
#
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
# [SECTION: IMPORTS]
from passlib.context import CryptContext
# [/SECTION]
# [DEF:pwd_context:Variable]
# @PURPOSE: Passlib CryptContext for password management.
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# [/DEF:pwd_context:Variable]
# [DEF:verify_password:Function]
# @PURPOSE: Verifies a plain password against a hashed password.
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
# @POST: Returns True if password matches, False otherwise.
#
# @PARAM: plain_password (str) - The unhashed password.
# @PARAM: hashed_password (str) - The stored hash.
# @RETURN: bool - Verification result.
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
# [/DEF:verify_password:Function]
# [DEF:get_password_hash:Function]
# @PURPOSE: Generates a bcrypt hash for a plain password.
# @PRE: password is a string.
# @POST: Returns a secure bcrypt hash string.
#
# @PARAM: password (str) - The password to hash.
# @RETURN: str - The generated hash.
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
# [/DEF:get_password_hash:Function]
# [/DEF:backend.src.core.auth.security:Module]

View File

@@ -5,6 +5,7 @@
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.mapping
# @RELATION: USES -> backend.src.core.auth.config
#
# @INVARIANT: A single engine instance is used for the entire application.
@@ -16,44 +17,70 @@ from ..models.mapping import Base
from ..models.task import TaskRecord
from ..models.connection import ConnectionConfig
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
from ..models.auth import User, Role, Permission, ADGroupMapping
from .logger import belief_scope
from .auth.config import auth_config
import os
# [/SECTION]
# [DEF:DATABASE_URL:Constant]
# @PURPOSE: URL for the main mappings database.
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
# [/DEF:DATABASE_URL:Constant]
# [DEF:TASKS_DATABASE_URL:Constant]
# @PURPOSE: URL for the tasks execution database.
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
# [/DEF:TASKS_DATABASE_URL:Constant]
# [DEF:AUTH_DATABASE_URL:Constant]
# @PURPOSE: URL for the authentication database.
AUTH_DATABASE_URL = auth_config.AUTH_DATABASE_URL
# [/DEF:AUTH_DATABASE_URL:Constant]
# [DEF:engine:Variable]
# @PURPOSE: SQLAlchemy engine for mappings database.
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:engine:Variable]
# [DEF:tasks_engine:Variable]
# @PURPOSE: SQLAlchemy engine for tasks database.
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:tasks_engine:Variable]
# [DEF:auth_engine:Variable]
# @PURPOSE: SQLAlchemy engine for authentication database.
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:auth_engine:Variable]
# [DEF:SessionLocal:Class]
# @PURPOSE: A session factory for the main mappings database.
# @PRE: engine is initialized.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# [/DEF:SessionLocal:Class]
# [DEF:TasksSessionLocal:Class]
# @PURPOSE: A session factory for the tasks execution database.
# @PRE: tasks_engine is initialized.
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
# [/DEF:TasksSessionLocal:Class]
# [DEF:AuthSessionLocal:Class]
# @PURPOSE: A session factory for the authentication database.
# @PRE: auth_engine is initialized.
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
# [/DEF:AuthSessionLocal:Class]
# [DEF:init_db:Function]
# @PURPOSE: Initializes the database by creating all tables.
# @PRE: engine and tasks_engine are initialized.
# @POST: Database tables created.
# @PRE: engine, tasks_engine and auth_engine are initialized.
# @POST: Database tables created in all databases.
# @SIDE_EFFECT: Creates physical database files if they don't exist.
def init_db():
with belief_scope("init_db"):
Base.metadata.create_all(bind=engine)
Base.metadata.create_all(bind=tasks_engine)
Base.metadata.create_all(bind=auth_engine)
# [/DEF:init_db:Function]
# [DEF:get_db:Function]
@@ -84,4 +111,18 @@ def get_tasks_db():
db.close()
# [/DEF:get_tasks_db:Function]
# [DEF:get_auth_db:Function]
# @PURPOSE: Dependency for getting an authentication database session.
# @PRE: AuthSessionLocal is initialized.
# @POST: Session is closed after use.
# @RETURN: Generator[Session, None, None]
def get_auth_db():
with belief_scope("get_auth_db"):
db = AuthSessionLocal()
try:
yield db
finally:
db.close()
# [/DEF:get_auth_db:Function]
# [/DEF:backend.src.core.database:Module]

View File

@@ -68,6 +68,18 @@ class PluginBase(ABC):
pass
# [/DEF:version:Function]
@property
# [DEF:required_permission:Function]
# @PURPOSE: Returns the required permission string to execute this plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string permission.
# @RETURN: str - Required permission (e.g., "plugin:backup:execute").
def required_permission(self) -> str:
"""The permission string required to execute this plugin."""
with belief_scope("required_permission"):
return f"plugin:{self.id}:execute"
# [/DEF:required_permission:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.

View File

@@ -1,16 +1,23 @@
# [DEF:Dependencies:Module]
# @SEMANTICS: dependency, injection, singleton, factory
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
# @LAYER: Core
# @RELATION: Used by the main app and API routers to get access to shared instances.
from pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from .core.plugin_loader import PluginLoader
from .core.task_manager import TaskManager
from .core.config_manager import ConfigManager
from .core.scheduler import SchedulerService
from .core.database import init_db
from .core.database import init_db, get_auth_db
from .core.logger import logger, belief_scope
from .core.auth.jwt import decode_token
from .core.auth.repository import AuthRepository
from .models.auth import User
# Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
@@ -77,4 +84,70 @@ def get_scheduler_service() -> SchedulerService:
return scheduler_service
# [/DEF:get_scheduler_service:Function]
# [DEF:oauth2_scheme:Variable]
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# [/DEF:oauth2_scheme:Variable]
# [DEF:get_current_user:Function]
# @PURPOSE: Dependency for retrieving the currently authenticated user from a JWT.
# @PRE: JWT token provided in Authorization header.
# @POST: Returns the User object if token is valid.
# @THROW: HTTPException 401 if token is invalid or user not found.
# @PARAM: token (str) - Extracted JWT token.
# @PARAM: db (Session) - Auth database session.
# @RETURN: User - The authenticated user.
def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)):
with belief_scope("get_current_user"):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
repo = AuthRepository(db)
user = repo.get_user_by_username(username)
if user is None:
raise credentials_exception
return user
# [/DEF:get_current_user:Function]
# [DEF:has_permission:Function]
# @PURPOSE: Dependency for checking if the current user has a specific permission.
# @PRE: User is authenticated.
# @POST: Returns True if user has permission.
# @THROW: HTTPException 403 if permission is denied.
# @PARAM: resource (str) - The resource identifier.
# @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE).
# @RETURN: User - The authenticated user if permission granted.
def has_permission(resource: str, action: str):
def permission_checker(current_user: User = Depends(get_current_user)):
with belief_scope("has_permission", f"{resource}:{action}"):
# Union of all permissions across all roles
for role in current_user.roles:
for perm in role.permissions:
if perm.resource == resource and perm.action == action:
return current_user
# Special case for Admin role (full access)
if any(role.name == "Admin" for role in current_user.roles):
return current_user
from .core.auth.logger import log_security_event
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action})
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied for {resource}:{action}"
)
return permission_checker
# [/DEF:has_permission:Function]
# [/DEF:Dependencies:Module]

105
backend/src/models/auth.py Normal file
View File

@@ -0,0 +1,105 @@
# [DEF:backend.src.models.auth:Module]
#
# @TIER: STANDARD
# @SEMANTICS: auth, models, user, role, permission, sqlalchemy
# @PURPOSE: SQLAlchemy models for multi-user authentication and authorization.
# @LAYER: Domain
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
#
# @INVARIANT: Usernames and emails must be unique.
# [SECTION: IMPORTS]
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table, Enum
from sqlalchemy.orm import relationship
from .mapping import Base
# [/SECTION]
# [DEF:generate_uuid:Function]
# @PURPOSE: Generates a unique UUID string.
# @POST: Returns a string representation of a new UUID.
def generate_uuid():
return str(uuid.uuid4())
# [/DEF:generate_uuid:Function]
# [DEF:user_roles:Table]
# @PURPOSE: Association table for many-to-many relationship between Users and Roles.
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", String, ForeignKey("users.id"), primary_key=True),
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
)
# [/DEF:user_roles:Table]
# [DEF:role_permissions:Table]
# @PURPOSE: Association table for many-to-many relationship between Roles and Permissions.
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
Column("permission_id", String, ForeignKey("permissions.id"), primary_key=True),
)
# [/DEF:role_permissions:Table]
# [DEF:User:Class]
# @PURPOSE: Represents an identity that can authenticate to the system.
# @RELATION: HAS_MANY -> Role (via user_roles)
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=generate_uuid)
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True, nullable=True)
password_hash = Column(String, nullable=True)
auth_source = Column(String, default="LOCAL") # LOCAL or ADFS
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True)
roles = relationship("Role", secondary=user_roles, back_populates="users")
# [/DEF:User:Class]
# [DEF:Role:Class]
# @PURPOSE: Represents a collection of permissions.
# @RELATION: HAS_MANY -> User (via user_roles)
# @RELATION: HAS_MANY -> Permission (via role_permissions)
class Role(Base):
__tablename__ = "roles"
id = Column(String, primary_key=True, default=generate_uuid)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(String, nullable=True)
users = relationship("User", secondary=user_roles, back_populates="roles")
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
# [/DEF:Role:Class]
# [DEF:Permission:Class]
# @PURPOSE: Represents a specific capability within the system.
# @RELATION: HAS_MANY -> Role (via role_permissions)
class Permission(Base):
__tablename__ = "permissions"
id = Column(String, primary_key=True, default=generate_uuid)
resource = Column(String, nullable=False) # e.g. "plugin:backup"
action = Column(String, nullable=False) # e.g. "READ", "EXECUTE", "WRITE"
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
# [/DEF:Permission:Class]
# [DEF:ADGroupMapping:Class]
# @PURPOSE: Maps an Active Directory group to a local System Role.
# @RELATION: DEPENDS_ON -> Role
class ADGroupMapping(Base):
__tablename__ = "ad_group_mappings"
id = Column(String, primary_key=True, default=generate_uuid)
ad_group = Column(String, unique=True, index=True, nullable=False)
role_id = Column(String, ForeignKey("roles.id"), nullable=False)
role = relationship("Role")
# [/DEF:ADGroupMapping:Class]
# [/DEF:backend.src.models.auth:Module]

View File

@@ -1,4 +1,5 @@
# [DEF:backend.src.models.dashboard:Module]
# @TIER: STANDARD
# @SEMANTICS: dashboard, model, metadata, migration
# @PURPOSE: Defines data models for dashboard metadata and selection.
# @LAYER: Model
@@ -8,6 +9,7 @@ from pydantic import BaseModel
from typing import List
# [DEF:DashboardMetadata:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents a dashboard available for migration.
class DashboardMetadata(BaseModel):
id: int
@@ -17,6 +19,7 @@ class DashboardMetadata(BaseModel):
# [/DEF:DashboardMetadata:Class]
# [DEF:DashboardSelection:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents the user's selection of dashboards to migrate.
class DashboardSelection(BaseModel):
selected_ids: List[int]

View File

@@ -1,5 +1,6 @@
# [DEF:backend.src.models.mapping:Module]
#
# @TIER: STANDARD
# @SEMANTICS: database, mapping, environment, migration, sqlalchemy, sqlite
# @PURPOSE: Defines the database schema for environment metadata and database mappings using SQLAlchemy.
# @LAYER: Domain
@@ -19,6 +20,7 @@ import enum
Base = declarative_base()
# [DEF:MigrationStatus:Class]
# @TIER: TRIVIAL
# @PURPOSE: Enumeration of possible migration job statuses.
class MigrationStatus(enum.Enum):
PENDING = "PENDING"
@@ -29,6 +31,7 @@ class MigrationStatus(enum.Enum):
# [/DEF:MigrationStatus:Class]
# [DEF:Environment:Class]
# @TIER: STANDARD
# @PURPOSE: Represents a Superset instance environment.
class Environment(Base):
__tablename__ = "environments"
@@ -40,6 +43,7 @@ class Environment(Base):
# [/DEF:Environment:Class]
# [DEF:DatabaseMapping:Class]
# @TIER: STANDARD
# @PURPOSE: Represents a mapping between source and target databases.
class DatabaseMapping(Base):
__tablename__ = "database_mappings"

View File

@@ -303,9 +303,9 @@ class MigrationPlugin(PluginBase):
try:
exported_content, _ = from_c.export_dashboard(dash_id)
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip", logger=logger) as tmp_zip_path:
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip") as tmp_zip_path:
# Always transform to strip databases to avoid password errors
with create_temp_file(suffix=".zip", dry_run=True, logger=logger) as tmp_new_zip:
with create_temp_file(suffix=".zip", dry_run=True) as tmp_new_zip:
success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False)
if not success and replace_db_config:

128
backend/src/schemas/auth.py Normal file
View File

@@ -0,0 +1,128 @@
# [DEF:backend.src.schemas.auth:Module]
#
# @TIER: STANDARD
# @SEMANTICS: auth, schemas, pydantic, user, token
# @PURPOSE: Pydantic schemas for authentication requests and responses.
# @LAYER: API
# @RELATION: DEPENDS_ON -> pydantic
#
# @INVARIANT: Sensitive fields like password must not be included in response schemas.
# [SECTION: IMPORTS]
from typing import List, Optional
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime
# [/SECTION]
# [DEF:Token:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents a JWT access token response.
class Token(BaseModel):
access_token: str
token_type: str
# [/DEF:Token:Class]
# [DEF:TokenData:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents the data encoded in a JWT token.
class TokenData(BaseModel):
username: Optional[str] = None
scopes: List[str] = []
# [/DEF:TokenData:Class]
# [DEF:PermissionSchema:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents a permission in API responses.
class PermissionSchema(BaseModel):
id: Optional[str] = None
resource: str
action: str
class Config:
from_attributes = True
# [/DEF:PermissionSchema:Class]
# [DEF:RoleSchema:Class]
# @PURPOSE: Represents a role in API responses.
class RoleSchema(BaseModel):
id: str
name: str
description: Optional[str] = None
permissions: List[PermissionSchema] = []
class Config:
from_attributes = True
# [/DEF:RoleSchema:Class]
# [DEF:RoleCreate:Class]
# @PURPOSE: Schema for creating a new role.
class RoleCreate(BaseModel):
name: str
description: Optional[str] = None
permissions: List[str] = [] # List of permission IDs or "resource:action" strings
# [/DEF:RoleCreate:Class]
# [DEF:RoleUpdate:Class]
# @PURPOSE: Schema for updating an existing role.
class RoleUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
permissions: Optional[List[str]] = None
# [/DEF:RoleUpdate:Class]
# [DEF:ADGroupMappingSchema:Class]
# @PURPOSE: Represents an AD Group to Role mapping in API responses.
class ADGroupMappingSchema(BaseModel):
id: str
ad_group: str
role_id: str
class Config:
from_attributes = True
# [/DEF:ADGroupMappingSchema:Class]
# [DEF:ADGroupMappingCreate:Class]
# @PURPOSE: Schema for creating an AD Group mapping.
class ADGroupMappingCreate(BaseModel):
ad_group: str
role_id: str
# [/DEF:ADGroupMappingCreate:Class]
# [DEF:UserBase:Class]
# @PURPOSE: Base schema for user data.
class UserBase(BaseModel):
username: str
email: Optional[EmailStr] = None
is_active: bool = True
# [/DEF:UserBase:Class]
# [DEF:UserCreate:Class]
# @PURPOSE: Schema for creating a new user.
class UserCreate(UserBase):
password: str
roles: List[str] = []
# [/DEF:UserCreate:Class]
# [DEF:UserUpdate:Class]
# @PURPOSE: Schema for updating an existing user.
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
password: Optional[str] = None
is_active: Optional[bool] = None
roles: Optional[List[str]] = None
# [/DEF:UserUpdate:Class]
# [DEF:User:Class]
# @PURPOSE: Schema for user data in API responses.
class User(UserBase):
id: str
auth_source: str
created_at: datetime
last_login: Optional[datetime] = None
roles: List[RoleSchema] = []
class Config:
from_attributes = True
# [/DEF:User:Class]
# [/DEF:backend.src.schemas.auth:Module]

View File

@@ -0,0 +1,82 @@
# [DEF:backend.src.scripts.create_admin:Module]
#
# @SEMANTICS: admin, setup, user, auth, cli
# @PURPOSE: CLI tool for creating the initial admin user.
# @LAYER: Scripts
# @RELATION: USES -> backend.src.core.auth.security
# @RELATION: USES -> backend.src.core.database
# @RELATION: USES -> backend.src.models.auth
#
# @INVARIANT: Admin user must have the "Admin" role.
# [SECTION: IMPORTS]
import sys
import argparse
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.database import AuthSessionLocal, init_db
from src.core.auth.security import get_password_hash
from src.models.auth import User, Role, Permission
from src.core.logger import logger, belief_scope
# [/SECTION]
# [DEF:create_admin:Function]
# @PURPOSE: Creates an admin user and necessary roles/permissions.
# @PRE: username and password provided via CLI.
# @POST: Admin user exists in auth.db.
#
# @PARAM: username (str) - Admin username.
# @PARAM: password (str) - Admin password.
def create_admin(username, password):
with belief_scope("create_admin"):
db = AuthSessionLocal()
try:
# 1. Ensure Admin role exists
admin_role = db.query(Role).filter(Role.name == "Admin").first()
if not admin_role:
logger.info("Creating Admin role...")
admin_role = Role(name="Admin", description="System Administrator")
db.add(admin_role)
db.commit()
db.refresh(admin_role)
# 2. Check if user already exists
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
logger.warning(f"User {username} already exists.")
return
# 3. Create Admin user
logger.info(f"Creating admin user: {username}")
new_user = User(
username=username,
password_hash=get_password_hash(password),
auth_source="LOCAL",
is_active=True
)
new_user.roles.append(admin_role)
db.add(new_user)
db.commit()
logger.info(f"Admin user {username} created successfully.")
except Exception as e:
logger.error(f"Failed to create admin user: {e}")
db.rollback()
finally:
db.close()
# [/DEF:create_admin:Function]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Create initial admin user")
parser.add_argument("--username", required=True, help="Admin username")
parser.add_argument("--password", required=True, help="Admin password")
args = parser.parse_args()
# Ensure DB is initialized before creating admin
init_db()
create_admin(args.username, args.password)
# [/DEF:backend.src.scripts.create_admin:Module]

View File

@@ -0,0 +1,44 @@
# [DEF:backend.src.scripts.init_auth_db:Module]
#
# @SEMANTICS: setup, database, auth, migration
# @PURPOSE: Initializes the auth database and creates the necessary tables.
# @LAYER: Scripts
# @RELATION: CALLS -> backend.src.core.database.init_db
#
# @INVARIANT: Safe to run multiple times (idempotent).
# [SECTION: IMPORTS]
import sys
import os
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.database import init_db, auth_engine
from src.core.logger import logger, belief_scope
from src.scripts.seed_permissions import seed_permissions
# [/SECTION]
# [DEF:run_init:Function]
# @PURPOSE: Main entry point for the initialization script.
# @POST: auth.db is initialized with the correct schema and seeded permissions.
def run_init():
with belief_scope("init_auth_db"):
logger.info("Initializing authentication database...")
try:
init_db()
logger.info("Authentication database initialized successfully.")
# Seed permissions
seed_permissions()
except Exception as e:
logger.error(f"Failed to initialize authentication database: {e}")
sys.exit(1)
# [/DEF:run_init:Function]
if __name__ == "__main__":
run_init()
# [/DEF:backend.src.scripts.init_auth_db:Module]

View File

@@ -0,0 +1,116 @@
# [DEF:backend.src.scripts.seed_permissions:Module]
#
# @SEMANTICS: setup, database, auth, permissions, seeding
# @PURPOSE: Populates the auth database with initial system permissions.
# @LAYER: Scripts
# @RELATION: USES -> backend.src.core.database.get_auth_db
# @RELATION: USES -> backend.src.models.auth.Permission
#
# @INVARIANT: Safe to run multiple times (idempotent).
# [SECTION: IMPORTS]
import sys
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.database import AuthSessionLocal
from src.models.auth import Permission, Role
from src.core.auth.repository import AuthRepository
from src.core.logger import logger, belief_scope
# [/SECTION]
# [DEF:INITIAL_PERMISSIONS:Constant]
INITIAL_PERMISSIONS = [
# Admin Permissions
{"resource": "admin:users", "action": "READ"},
{"resource": "admin:users", "action": "WRITE"},
{"resource": "admin:roles", "action": "READ"},
{"resource": "admin:roles", "action": "WRITE"},
{"resource": "admin:settings", "action": "READ"},
{"resource": "admin:settings", "action": "WRITE"},
{"resource": "environments", "action": "READ"},
{"resource": "plugins", "action": "READ"},
{"resource": "tasks", "action": "READ"},
{"resource": "tasks", "action": "WRITE"},
# Plugin Permissions
{"resource": "plugin:backup", "action": "EXECUTE"},
{"resource": "plugin:migration", "action": "EXECUTE"},
{"resource": "plugin:mapper", "action": "EXECUTE"},
{"resource": "plugin:search", "action": "EXECUTE"},
{"resource": "plugin:git", "action": "EXECUTE"},
{"resource": "plugin:storage", "action": "EXECUTE"},
{"resource": "plugin:storage", "action": "READ"},
{"resource": "plugin:storage", "action": "WRITE"},
{"resource": "plugin:debug", "action": "EXECUTE"},
]
# [/DEF:INITIAL_PERMISSIONS:Constant]
# [DEF:seed_permissions:Function]
# @PURPOSE: Inserts missing permissions into the database.
# @POST: All INITIAL_PERMISSIONS exist in the DB.
def seed_permissions():
with belief_scope("seed_permissions"):
db = AuthSessionLocal()
try:
logger.info("Seeding permissions...")
count = 0
for perm_data in INITIAL_PERMISSIONS:
exists = db.query(Permission).filter(
Permission.resource == perm_data["resource"],
Permission.action == perm_data["action"]
).first()
if not exists:
new_perm = Permission(
resource=perm_data["resource"],
action=perm_data["action"]
)
db.add(new_perm)
count += 1
db.commit()
logger.info(f"Seeding completed. Added {count} new permissions.")
# Assign permissions to User role
repo = AuthRepository(db)
user_role = repo.get_role_by_name("User")
if not user_role:
user_role = Role(name="User", description="Standard user with plugin access")
db.add(user_role)
db.flush()
user_permissions = [
("plugin:mapper", "EXECUTE"),
("plugin:migration", "EXECUTE"),
("plugin:backup", "EXECUTE"),
("plugin:git", "EXECUTE"),
("plugin:storage", "READ"),
("plugin:storage", "WRITE"),
("environments", "READ"),
("plugins", "READ"),
("tasks", "READ"),
("tasks", "WRITE"),
]
for res, act in user_permissions:
perm = repo.get_permission_by_resource_action(res, act)
if perm and perm not in user_role.permissions:
user_role.permissions.append(perm)
db.commit()
logger.info("User role permissions updated.")
except Exception as e:
logger.error(f"Failed to seed permissions: {e}")
db.rollback()
finally:
db.close()
# [/DEF:seed_permissions:Function]
if __name__ == "__main__":
seed_permissions()
# [/DEF:backend.src.scripts.seed_permissions:Module]

View File

@@ -0,0 +1,115 @@
# [DEF:backend.src.services.auth_service:Module]
#
# @SEMANTICS: auth, service, business-logic, login, jwt
# @PURPOSE: Orchestrates authentication business logic.
# @LAYER: Service
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
# @RELATION: USES -> backend.src.core.auth.security
# @RELATION: USES -> backend.src.core.auth.jwt
#
# @INVARIANT: Authentication must verify both credentials and account status.
# [SECTION: IMPORTS]
from typing import Optional, Dict, Any, List
from sqlalchemy.orm import Session
from ..models.auth import User, Role
from ..core.auth.repository import AuthRepository
from ..core.auth.security import verify_password, get_password_hash
from ..core.auth.jwt import create_access_token
from ..core.logger import belief_scope
# [/SECTION]
# [DEF:AuthService:Class]
# @PURPOSE: Provides high-level authentication services.
class AuthService:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the service with a database session.
# @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session):
self.repo = AuthRepository(db)
# [/DEF:__init__:Function]
# [DEF:authenticate_user:Function]
# @PURPOSE: Authenticates a user with username and password.
# @PRE: username and password are provided.
# @POST: Returns User object if authentication succeeds, else None.
# @SIDE_EFFECT: Updates last_login timestamp on success.
# @PARAM: username (str) - The username.
# @PARAM: password (str) - The plain password.
# @RETURN: Optional[User] - The authenticated user or None.
def authenticate_user(self, username: str, password: str):
with belief_scope("AuthService.authenticate_user"):
user = self.repo.get_user_by_username(username)
if not user:
return None
if not user.is_active:
return None
if not user.password_hash or not verify_password(password, user.password_hash):
return None
self.repo.update_last_login(user)
return user
# [/DEF:authenticate_user:Function]
# [DEF:create_session:Function]
# @PURPOSE: Creates a JWT session for an authenticated user.
# @PRE: user is a valid User object.
# @POST: Returns a dictionary with access_token and token_type.
# @PARAM: user (User) - The authenticated user.
# @RETURN: Dict[str, str] - Session data.
def create_session(self, user) -> Dict[str, str]:
with belief_scope("AuthService.create_session"):
# Collect role names for scopes
scopes = [role.name for role in user.roles]
token_data = {
"sub": user.username,
"scopes": scopes
}
access_token = create_access_token(data=token_data)
return {
"access_token": access_token,
"token_type": "bearer"
}
# [/DEF:create_session:Function]
# [DEF:provision_adfs_user:Function]
# @PURPOSE: Just-In-Time (JIT) provisioning for ADFS users based on group mappings.
# @PRE: user_info contains 'upn' (username), 'email', and 'groups'.
# @POST: User is created/updated and assigned roles based on groups.
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
# @RETURN: User - The provisioned user.
def provision_adfs_user(self, user_info: Dict[str, Any]) -> User:
with belief_scope("AuthService.provision_adfs_user"):
username = user_info.get("upn") or user_info.get("email")
email = user_info.get("email")
ad_groups = user_info.get("groups", [])
user = self.repo.get_user_by_username(username)
if not user:
user = User(
username=username,
email=email,
auth_source="ADFS",
is_active=True
)
self.repo.db.add(user)
# Update roles based on group mappings
from ..models.auth import ADGroupMapping
mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter(
ADGroupMapping.ad_group.in_(ad_groups)
).all()
user.roles = mapped_roles
self.repo.db.commit()
self.repo.db.refresh(user)
return user
# [/DEF:provision_adfs_user:Function]
# [/DEF:AuthService:Class]
# [/DEF:backend.src.services.auth_service:Module]

View File

@@ -10,9 +10,9 @@
# [SECTION: IMPORTS]
from typing import List, Dict
from backend.src.core.logger import belief_scope
from backend.src.core.superset_client import SupersetClient
from backend.src.core.utils.matching import suggest_mappings
from ..core.logger import belief_scope
from ..core.superset_client import SupersetClient
from ..core.utils.matching import suggest_mappings
# [/SECTION]
# [DEF:MappingService:Class]

Binary file not shown.

162
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,162 @@
import sys
import os
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent / "src"))
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.core.database import Base, get_auth_db
from src.models.auth import User, Role, Permission, ADGroupMapping
from src.services.auth_service import AuthService
from src.core.auth.repository import AuthRepository
from src.core.auth.security import verify_password, get_password_hash
# Create in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create all tables
Base.metadata.create_all(bind=engine)
@pytest.fixture
def db_session():
"""Create a new database session with a transaction, rollback after test"""
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def auth_service(db_session):
return AuthService(db_session)
@pytest.fixture
def auth_repo(db_session):
return AuthRepository(db_session)
def test_create_user(auth_repo):
"""Test user creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("testuser")
assert retrieved_user is not None
assert retrieved_user.username == "testuser"
assert retrieved_user.email == "test@example.com"
assert verify_password("testpassword123", retrieved_user.password_hash)
def test_authenticate_user(auth_service, auth_repo):
"""Test user authentication with valid and invalid credentials"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
# Test valid credentials
authenticated_user = auth_service.authenticate_user("testuser", "testpassword123")
assert authenticated_user is not None
assert authenticated_user.username == "testuser"
# Test invalid password
invalid_user = auth_service.authenticate_user("testuser", "wrongpassword")
assert invalid_user is None
# Test invalid username
invalid_user = auth_service.authenticate_user("nonexistent", "testpassword123")
assert invalid_user is None
def test_create_session(auth_service, auth_repo):
"""Test session token creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
session = auth_service.create_session(user)
assert "access_token" in session
assert "token_type" in session
assert session["token_type"] == "bearer"
assert len(session["access_token"]) > 0
def test_role_permission_association(auth_repo):
"""Test role and permission association"""
role = Role(name="Admin", description="System administrator")
perm1 = Permission(resource="admin:users", action="READ")
perm2 = Permission(resource="admin:users", action="WRITE")
role.permissions.extend([perm1, perm2])
auth_repo.db.add(role)
auth_repo.db.commit()
retrieved_role = auth_repo.get_role_by_name("Admin")
assert retrieved_role is not None
assert len(retrieved_role.permissions) == 2
permissions = [f"{p.resource}:{p.action}" for p in retrieved_role.permissions]
assert "admin:users:READ" in permissions
assert "admin:users:WRITE" in permissions
def test_user_role_association(auth_repo):
"""Test user and role association"""
role = Role(name="Admin", description="System administrator")
user = User(
username="adminuser",
email="admin@example.com",
password_hash=get_password_hash("adminpass123"),
auth_source="LOCAL"
)
user.roles.append(role)
auth_repo.db.add(role)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("adminuser")
assert retrieved_user is not None
assert len(retrieved_user.roles) == 1
assert retrieved_user.roles[0].name == "Admin"
def test_ad_group_mapping(auth_repo):
"""Test AD group mapping"""
role = Role(name="ADFS_Admin", description="ADFS administrators")
auth_repo.db.add(role)
auth_repo.db.commit()
mapping = ADGroupMapping(ad_group="DOMAIN\\ADFS_Admins", role_id=role.id)
auth_repo.db.add(mapping)
auth_repo.db.commit()
retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first()
assert retrieved_mapping is not None
assert retrieved_mapping.role_id == role.id

View File

@@ -9,6 +9,13 @@
import { page } from '$app/stores';
import { t } from '$lib/i18n';
import { LanguageSwitcher } from '$lib/ui';
import { auth } from '../lib/auth/store';
import { goto } from '$app/navigation';
function handleLogout() {
auth.logout();
goto('/login');
}
</script>
<header class="bg-white shadow-md p-4 flex justify-between items-center">
@@ -41,7 +48,32 @@
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a>
</div>
</div>
{#if $auth.isAuthenticated && $auth.user?.roles?.some(r => r.name === 'Admin')}
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/admin') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
{$t.nav.admin}
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/admin/users" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_users}</a>
<a href="/admin/roles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_roles}</a>
<a href="/admin/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_settings}</a>
</div>
</div>
{/if}
<LanguageSwitcher />
{#if $auth.isAuthenticated}
<div class="flex items-center space-x-2 border-l pl-4 ml-4">
<span class="text-sm text-gray-600">{$auth.user?.username}</span>
<button
on:click={handleLogout}
class="text-sm text-red-600 hover:text-red-800 font-medium"
>
Logout
</button>
</div>
{/if}
</nav>
</header>
<!-- [/DEF:Navbar:Component] -->

View File

@@ -21,7 +21,9 @@
// @POST: tasks array is updated and selectedTask status synchronized.
async function fetchTasks() {
try {
const res = await fetch('/api/tasks?limit=10');
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch('/api/tasks?limit=10', { headers });
if (!res.ok) throw new Error('Failed to fetch tasks');
tasks = await res.json();
@@ -58,7 +60,9 @@
const params = new URLSearchParams();
if (status) params.append('status', status);
const res = await fetch(`${url}?${params.toString()}`, { method: 'DELETE' });
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch(`${url}?${params.toString()}`, { method: 'DELETE', headers });
if (!res.ok) throw new Error('Failed to clear tasks');
await fetchTasks();
@@ -75,7 +79,9 @@
async function selectTask(task) {
try {
// Fetch the full task details (including logs) before setting it as selected
const res = await fetch(`/api/tasks/${task.id}`);
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch(`/api/tasks/${task.id}`, { headers });
if (res.ok) {
const fullTask = await res.json();
selectedTask.set(fullTask);

View File

@@ -0,0 +1,61 @@
<!-- [DEF:ProtectedRoute:Component] -->
<!--
@SEMANTICS: auth, guard, route, protection
@PURPOSE: Wraps content to ensure only authenticated users can access it.
@LAYER: Component
@RELATION: USES -> authStore
@RELATION: CALLS -> goto
@INVARIANT: Redirects to /login if user is not authenticated.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '../../lib/auth/store';
import { goto } from '$app/navigation';
// [SECTION: TEMPLATE]
// Only render slot if authenticated
// [/SECTION: TEMPLATE]
onMount(async () => {
// Check if we have a token but no user profile yet
if ($auth.token && !$auth.user) {
auth.setLoading(true);
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${$auth.token}`
}
});
if (response.ok) {
const user = await response.json();
auth.setUser(user);
} else {
// Token invalid or expired
auth.logout();
goto('/login');
}
} catch (e) {
console.error('Failed to verify session:', e);
auth.logout();
goto('/login');
} finally {
auth.setLoading(false);
}
} else if (!$auth.token) {
goto('/login');
}
});
</script>
{#if $auth.loading}
<div class="flex items-center justify-center min-h-screen">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
{:else if $auth.isAuthenticated}
<slot />
{/if}
<!-- [/DEF:ProtectedRoute:Component] -->

View File

@@ -1,8 +1,9 @@
<!-- [DEF:FileList:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: storage, files, list, table
@PURPOSE: Displays a table of files with metadata and actions.
@LAYER: Component
@LAYER: UI
@RELATION: DEPENDS_ON -> storageService
@PROPS: files (Array) - List of StoredFile objects.
@@ -22,10 +23,13 @@
// [DEF:isDirectory:Function]
/**
* @purpose Checks if a file object represents a directory.
* @pre file object has mime_type property.
* @post Returns boolean.
* @param {Object} file - The file object to check.
* @return {boolean} True if it's a directory, false otherwise.
*/
function isDirectory(file) {
console.log("[isDirectory][Action] Checking file type");
return file.mime_type === 'directory';
}
// [/DEF:isDirectory:Function]
@@ -33,10 +37,13 @@
// [DEF:formatSize:Function]
/**
* @purpose Formats file size in bytes into a human-readable string.
* @pre bytes is a number.
* @post Returns formatted string.
* @param {number} bytes - The size in bytes.
* @return {string} Formatted size (e.g., "1.2 MB").
*/
function formatSize(bytes) {
console.log(`[formatSize][Action] Formatting ${bytes} bytes`);
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -48,10 +55,13 @@
// [DEF:formatDate:Function]
/**
* @purpose Formats an ISO date string into a localized readable format.
* @pre dateStr is a valid date string.
* @post Returns localized string.
* @param {string} dateStr - The date string to format.
* @return {string} Localized date and time.
*/
function formatDate(dateStr) {
console.log("[formatDate][Action] Formatting date string");
return new Date(dateStr).toLocaleString();
}
// [/DEF:formatDate:Function]

View File

@@ -1,8 +1,9 @@
<!-- [DEF:FileUpload:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: storage, upload, files
@PURPOSE: Provides a form for uploading files to a specific category.
@LAYER: Component
@LAYER: UI
@RELATION: DEPENDS_ON -> storageService
@PROPS: None

View File

@@ -8,6 +8,7 @@
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { api } from '../../lib/api.js';
import { runTask, getTaskStatus } from '../../services/toolsService.js';
import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js';
@@ -32,8 +33,7 @@
*/
async function fetchEnvironments() {
try {
const res = await fetch('/api/environments');
envs = await res.json();
envs = await api.getEnvironmentsList();
} catch (e) {
addToast('Failed to fetch environments', 'error');
}

View File

@@ -25,6 +25,22 @@ export const getWsUrl = (taskId) => {
};
// [/DEF:getWsUrl:Function]
// [DEF:getAuthHeaders:Function]
// @PURPOSE: Returns headers with Authorization if token exists.
function getAuthHeaders() {
const headers = {
'Content-Type': 'application/json',
};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
// [/DEF:getAuthHeaders:Function]
// [DEF:fetchApi:Function]
// @PURPOSE: Generic GET request wrapper.
// @PRE: endpoint string is provided.
@@ -34,10 +50,18 @@ export const getWsUrl = (taskId) => {
async function fetchApi(endpoint) {
try {
console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: getAuthHeaders()
});
console.log(`[api.fetchApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error);
@@ -59,14 +83,18 @@ async function postApi(endpoint, body) {
console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: getAuthHeaders(),
body: JSON.stringify(body),
});
console.log(`[api.postApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error);
@@ -85,21 +113,25 @@ async function requestApi(endpoint, method = 'GET', body = null) {
console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`);
const options = {
method,
headers: {
'Content-Type': 'application/json',
},
headers: getAuthHeaders(),
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
console.log(`[api.requestApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
console.error(`[api.requestApi][Action] Request failed context={{'status': ${response.status}, 'message': '${message}'}}`);
throw new Error(message);
}
if (response.status === 204) {
console.log('[api.requestApi][Action] 204 No Content received');
return null;
}
return await response.json();
} catch (error) {
console.error(`[api.requestApi][Coherence:Failed] Error ${method} to ${endpoint}:`, error);
@@ -112,6 +144,9 @@ async function requestApi(endpoint, method = 'GET', body = null) {
// [DEF:api:Data]
// @PURPOSE: API client object with specific methods.
export const api = {
fetchApi,
postApi,
requestApi,
getPlugins: () => fetchApi('/plugins'),
getTasks: () => fetchApi('/tasks'),
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),

View File

@@ -3,16 +3,28 @@
import Navbar from '../components/Navbar.svelte';
import Footer from '../components/Footer.svelte';
import Toast from '../components/Toast.svelte';
import ProtectedRoute from '../components/auth/ProtectedRoute.svelte';
import { page } from '$app/stores';
$: isLoginPage = $page.url.pathname === '/login';
</script>
<Toast />
<main class="bg-gray-50 min-h-screen flex flex-col">
<Navbar />
{#if isLoginPage}
<div class="p-4 flex-grow">
<slot />
</div>
{:else}
<ProtectedRoute>
<Navbar />
<div class="p-4 flex-grow">
<slot />
</div>
<div class="p-4 flex-grow">
<slot />
</div>
<Footer />
<Footer />
</ProtectedRoute>
{/if}
</main>

View File

@@ -0,0 +1,236 @@
<!-- [DEF:AdminRolesPage:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: admin, role-management, rbac
@PURPOSE: UI for managing system roles and their permissions.
@LAYER: Domain
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with Admin role.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let roles = [];
let permissions = [];
let loading = true;
let error = null;
let showModal = false;
let isEditing = false;
let currentRoleId = null;
let roleForm = {
name: '',
description: '',
permissions: []
};
// [DEF:loadData:Function]
/**
* @purpose Fetches roles and available permissions.
* @pre Component mounted.
* @post roles and permissions arrays populated.
*/
async function loadData() {
console.log('[AdminRolesPage][loadData][Entry]');
loading = true;
try {
[roles, permissions] = await Promise.all([
adminService.getRoles(),
adminService.getPermissions()
]);
console.log('[AdminRolesPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load roles data.";
console.error('[AdminRolesPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:openCreateModal:Function]
/**
* @purpose Initializes state for creating a new role.
* @pre None.
* @post showModal is true, roleForm is reset.
*/
function openCreateModal() {
console.log("[openCreateModal][Action] Opening create modal");
isEditing = false;
currentRoleId = null;
roleForm = { name: '', description: '', permissions: [] };
showModal = true;
}
// [/DEF:openCreateModal:Function]
// [DEF:openEditModal:Function]
/**
* @purpose Initializes state for editing an existing role.
* @pre role object is provided.
* @post showModal is true, roleForm is populated.
*/
function openEditModal(role) {
console.log(`[openEditModal][Action] Opening edit modal for role ${role.id}`);
isEditing = true;
currentRoleId = role.id;
roleForm = {
name: role.name,
description: role.description || '',
permissions: role.permissions.map(p => p.id)
};
showModal = true;
}
// [/DEF:openEditModal:Function]
// [DEF:handleSaveRole:Function]
/**
* @purpose Submits role data (create or update).
* @pre roleForm contains valid data.
* @post Role is saved, modal closed, data reloaded.
*/
async function handleSaveRole() {
console.log('[AdminRolesPage][handleSaveRole][Entry]');
try {
if (isEditing) {
await adminService.updateRole(currentRoleId, roleForm);
} else {
await adminService.createRole(roleForm);
}
showModal = false;
await loadData();
console.log('[AdminRolesPage][handleSaveRole][Coherence:OK]');
} catch (e) {
alert("Failed to save role: " + e.message);
console.error('[AdminRolesPage][handleSaveRole][Coherence:Failed]', e);
}
}
// [/DEF:handleSaveRole:Function]
// [DEF:handleDeleteRole:Function]
/**
* @purpose Deletes a role after confirmation.
* @pre role object is provided.
* @post Role is deleted if confirmed, data reloaded.
*/
async function handleDeleteRole(role) {
if (!confirm($t.admin.roles.confirm_delete.replace('{name}', role.name))) return;
console.log('[AdminRolesPage][handleDeleteRole][Entry]');
try {
await adminService.deleteRole(role.id);
await loadData();
console.log('[AdminRolesPage][handleDeleteRole][Coherence:OK]');
} catch (e) {
alert("Failed to delete role: " + e.message);
console.error('[AdminRolesPage][handleDeleteRole][Coherence:Failed]', e);
}
}
// [/DEF:handleDeleteRole:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:roles">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.roles.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
on:click={openCreateModal}
>
{$t.admin.roles.create}
</button>
</div>
{#if loading}
<p>{$t.admin.roles.loading}</p>
{:else if error}
<div class="bg-red-100 text-red-700 p-4 rounded">{error}</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.name}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.description}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.permissions}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each roles as role}
<tr>
<td class="px-6 py-4 whitespace-nowrap font-medium">{role.name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{role.description || '-'}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
{#each role.permissions as perm}
<span class="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded border border-blue-100">
{perm.resource}:{perm.action}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button on:click={() => openEditModal(role)} class="text-blue-600 hover:text-blue-900 mr-3">{$t.common.edit}</button>
<button on:click={() => handleDeleteRole(role)} class="text-red-600 hover:text-red-900">{$t.common.delete}</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 class="text-xl font-bold mb-4">
{isEditing ? $t.admin.roles.modal_edit_title : $t.admin.roles.modal_create_title}
</h2>
<form on:submit|preventDefault={handleSaveRole}>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">{$t.admin.roles.name}</label>
<input type="text" bind:value={roleForm.name} class="w-full border p-2 rounded" required readonly={isEditing} />
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">{$t.admin.roles.description}</label>
<textarea bind:value={roleForm.description} class="w-full border p-2 rounded h-20"></textarea>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">{$t.admin.roles.permissions}</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 border p-3 rounded bg-gray-50">
{#each permissions as perm}
<label class="flex items-center space-x-2 p-1 hover:bg-white rounded cursor-pointer">
<input type="checkbox" value={perm.id} bind:group={roleForm.permissions} class="rounded text-blue-600" />
<span class="text-xs">{perm.resource}:{perm.action}</span>
</label>
{/each}
</div>
<p class="text-xs text-gray-500 mt-2">{$t.admin.roles.permissions_hint}</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t">
<button type="button" class="px-4 py-2 text-gray-600" on:click={() => showModal = false}>{$t.common.cancel}</button>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">{$t.common.save}</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminRolesPage:Component] -->

View File

@@ -0,0 +1,213 @@
<!-- [DEF:AdminSettingsPage:Component] -->
<!--
@SEMANTICS: admin, adfs, mappings, configuration
@PURPOSE: UI for configuring Active Directory Group to local Role mappings for ADFS SSO.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with "admin:settings" permission.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let mappings = [];
let roles = [];
let loading = true;
let error = null;
let showCreateModal = false;
let newMapping = {
ad_group: '',
role_id: ''
};
// [DEF:loadData:Function]
/**
* @purpose Fetches AD mappings and roles from the backend to populate the UI.
* @pre Component is mounted and user has active session.
* @post mappings and roles variables are updated with backend data.
* @returns {Promise<void>}
* @side_effect Updates local 'mappings', 'roles', 'loading', and 'error' states.
* @relation CALLS -> adminService.getRoles
* @relation CALLS -> adminService.getADGroupMappings
*/
async function loadData() {
console.log('[AdminSettingsPage][loadData][Entry]');
loading = true;
try {
// Fetch roles first as they are required for displaying mapping labels
roles = await adminService.getRoles();
try {
mappings = await adminService.getADGroupMappings();
} catch (e) {
console.warn("[AdminSettingsPage][loadData] AD Mappings endpoint potentially unavailable.");
}
console.log('[AdminSettingsPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load roles or configuration.";
console.error('[AdminSettingsPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:handleCreateMapping:Function]
/**
* @purpose Submits a new AD Group to Role mapping to the backend.
* @pre 'newMapping' object contains valid 'ad_group' and 'role_id'.
* @post A new mapping is created in the database and the table is refreshed.
* @returns {Promise<void>}
* @side_effect Closes the modal on success, shows alert on failure.
* @relation CALLS -> adminService.createADGroupMapping
*/
async function handleCreateMapping() {
console.log('[AdminSettingsPage][handleCreateMapping][Entry]');
// Guard Clause (@PRE)
if (!newMapping.ad_group || !newMapping.role_id) {
alert("Please fill in all fields.");
return;
}
try {
await adminService.createADGroupMapping(newMapping);
showCreateModal = false;
// Reset form
newMapping = { ad_group: '', role_id: '' };
await loadData();
console.log('[AdminSettingsPage][handleCreateMapping][Coherence:OK]');
} catch (e) {
alert("Failed to create mapping: " + (e.message || "Unknown error"));
console.error('[AdminSettingsPage][handleCreateMapping][Coherence:Failed]', e);
}
}
// [/DEF:handleCreateMapping:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:settings">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.settings.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
on:click={() => showCreateModal = true}
>
{$t.admin.settings.add_mapping}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
</div>
{:else if error}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4" role="alert">
<p class="font-bold">{$t.common.error}</p>
<p>{error}</p>
</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.ad_group}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.local_role}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each mappings as mapping}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm text-gray-600">{mapping.ad_group}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
{roles.find(r => r.id === mapping.role_id)?.name || mapping.role_id}
</span>
</td>
</tr>
{/each}
{#if mappings.length === 0}
<tr>
<td colspan="2" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center">
<svg class="w-12 h-12 text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>{$t.admin.settings.no_mappings}</p>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{#if showCreateModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
<h2 class="text-xl font-bold mb-4 border-b pb-2">{$t.admin.settings.modal_title}</h2>
<form on:submit|preventDefault={handleCreateMapping}>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.ad_group_dn}</label>
<input
type="text"
bind:value={newMapping.ad_group}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g. CN=SS_ADMINS,OU=Groups,DC=org"
required
/>
<p class="text-xs text-gray-500 mt-1">{$t.admin.settings.ad_group_hint}</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.local_role_select}</label>
<select
bind:value={newMapping.role_id}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="" disabled>{$t.admin.settings.select_role}</option>
{#each roles as role}
<option value={role.id}>{role.name}</option>
{/each}
</select>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium"
on:click={() => showCreateModal = false}
>
{$t.common.cancel}
</button>
<button
type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md"
>
{$t.common.save}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminSettingsPage:Component] -->

View File

@@ -0,0 +1,284 @@
<!-- [DEF:AdminUsersPage:Component] -->
<!--
@SEMANTICS: admin, user-management, rbac
@PURPOSE: UI for managing system users and their roles.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with "admin:users" permission.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let users = [];
let roles = [];
let loading = true;
let error = null;
let deletingUserId = null;
let showModal = false;
let isEditing = false;
let currentUserId = null;
let userForm = {
username: '',
email: '',
password: '',
roles: [],
is_active: true
};
// [DEF:loadData:Function]
/**
* @purpose Fetches users and roles from the backend.
* @pre Component mounted.
* @post users and roles arrays populated.
*/
async function loadData() {
console.log('[AdminUsersPage][loadData][Entry]');
loading = true;
try {
[users, roles] = await Promise.all([
adminService.getUsers(),
adminService.getRoles()
]);
console.log('[AdminUsersPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load admin data.";
console.error('[AdminUsersPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:openCreateModal:Function]
/**
* @purpose Prepares the form for creating a new user.
* @post showModal is true, isEditing is false, userForm is reset.
*/
function openCreateModal() {
isEditing = false;
currentUserId = null;
userForm = { username: '', email: '', password: '', roles: [], is_active: true };
showModal = true;
}
// [/DEF:openCreateModal:Function]
// [DEF:openEditModal:Function]
/**
* @purpose Prepares the form for editing an existing user.
* @pre user object must be valid.
* @post showModal is true, isEditing is true, userForm populated with user data.
* @param {Object} user - The user object to edit.
*/
function openEditModal(user) {
isEditing = true;
currentUserId = user.id;
userForm = {
username: user.username,
email: user.email,
password: '',
roles: user.roles.map(r => r.name),
is_active: user.is_active
};
showModal = true;
}
// [/DEF:openEditModal:Function]
// [DEF:handleSaveUser:Function]
/**
* @purpose Submits user data to the backend (create or update).
* @pre userForm must be valid.
* @post User created or updated, modal closed, data reloaded.
* @side_effect Triggers API call to adminService.
* @relation CALLS -> adminService.createUser
* @relation CALLS -> adminService.updateUser
*/
async function handleSaveUser() {
console.log('[AdminUsersPage][handleSaveUser][Entry]');
try {
if (isEditing) {
const updateData = { ...userForm };
if (!updateData.password) delete updateData.password;
await adminService.updateUser(currentUserId, updateData);
} else {
await adminService.createUser(userForm);
}
showModal = false;
await loadData();
console.log('[AdminUsersPage][handleSaveUser][Coherence:OK]');
} catch (e) {
alert("Failed to save user: " + e.message);
console.error('[AdminUsersPage][handleSaveUser][Coherence:Failed]', e);
}
}
// [/DEF:handleSaveUser:Function]
// [DEF:handleDeleteUser:Function]
/**
* @purpose Deletes a user after confirmation.
* @pre user object must be valid.
* @post User deleted if confirmed, data reloaded.
* @side_effect Triggers API call to adminService.
* @relation CALLS -> adminService.deleteUser
* @param {Object} user - The user to delete.
*/
async function handleDeleteUser(user) {
if (deletingUserId) return;
if (!confirm($t.admin.users.confirm_delete.replace('{username}', user.username))) return;
console.log('[AdminUsersPage][handleDeleteUser][Entry]');
deletingUserId = user.id;
try {
await adminService.deleteUser(user.id);
await loadData();
console.log('[AdminUsersPage][handleDeleteUser][Coherence:OK]');
} catch (e) {
alert("Failed to delete user: " + e.message);
console.error('[AdminUsersPage][handleDeleteUser][Coherence:Failed]', e);
} finally {
deletingUserId = null;
}
}
// [/DEF:handleDeleteUser:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:users">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.users.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
on:click={openCreateModal}
>
{$t.admin.users.create}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
</div>
{:else if error}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4">
<p class="font-bold">{$t.common.error}</p>
<p>{error}</p>
</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.username}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.email}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.source}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.roles}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.status}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each users as user}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap font-medium">{user.username}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.email || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full {user.auth_source === 'LOCAL' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'}">
{user.auth_source}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1">
{#each user.roles as role}
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded border border-gray-200">{role.name}</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="flex items-center">
<span class="h-2 w-2 rounded-full mr-2 {user.is_active ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-sm {user.is_active ? 'text-green-700' : 'text-red-700'}">
{user.is_active ? $t.admin.users.active : $t.admin.users.inactive}
</span>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button on:click={() => openEditModal(user)} class="text-blue-600 hover:text-blue-900 mr-3" disabled={deletingUserId === user.id}>{$t.common.edit}</button>
<button
on:click={() => handleDeleteUser(user)}
class="text-red-600 hover:text-red-900 disabled:opacity-50"
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? ($t.common.deleting || 'Deleting...') : $t.common.delete}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
<h2 class="text-xl font-bold mb-4 border-b pb-2">
{isEditing ? $t.admin.users.modal_edit_title : $t.admin.users.modal_title}
</h2>
<form on:submit|preventDefault={handleSaveUser}>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.username}</label>
<input type="text" bind:value={userForm.username} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50" required readonly={isEditing} />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.email}</label>
<input type="email" bind:value={userForm.email} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.password}</label>
<input type="password" bind:value={userForm.password} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required={!isEditing} />
{#if isEditing}
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.password_hint}</p>
{/if}
</div>
<div class="mb-4">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" bind:checked={userForm.is_active} class="rounded text-blue-600 focus:ring-blue-500" />
<span class="text-sm font-medium text-gray-700">{$t.admin.users.active}</span>
</label>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.roles}</label>
<select multiple bind:value={userForm.roles} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-32">
{#each roles as role}
<option value={role.name}>{role.name}</option>
{/each}
</select>
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.roles_hint}</p>
</div>
<div class="flex justify-end gap-3 pt-4 border-t">
<button type="button" class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium" on:click={() => showModal = false}>{$t.common.cancel}</button>
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md transition-colors">{$t.common.save}</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminUsersPage:Component] -->

View File

@@ -8,6 +8,7 @@
import { onMount } from 'svelte';
import DashboardGrid from '../../components/DashboardGrid.svelte';
import { addToast as toast } from '../../lib/toasts.js';
import { api } from '../../lib/api.js';
import type { DashboardMetadata } from '../../types/dashboard';
import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui';
@@ -24,9 +25,7 @@
// @POST: `environments` array is populated with data from /api/environments.
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
environments = await api.getEnvironmentsList();
if (environments.length > 0) {
selectedEnvId = environments[0].id;
}
@@ -46,9 +45,7 @@
if (!envId) return;
fetchingDashboards = true;
try {
const response = await fetch(`/api/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
} catch (e) {
toast(e.message, 'error');
dashboards = [];

View File

@@ -0,0 +1,166 @@
<!-- [DEF:LoginPage:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: login, auth, ui, form
@PURPOSE: Provides the user interface for local and ADFS authentication.
@LAYER: UI
@RELATION: USES -> authStore
@RELATION: CALLS -> api.auth.login
@INVARIANT: Shows both local login form and ADFS SSO button.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '../../lib/auth/store';
import { goto } from '$app/navigation';
let username = '';
let password = '';
let error = '';
let loading = false;
// [DEF:handleLogin:Function]
/**
* @purpose Submits the local login form to the backend.
* @pre Username and password are not empty.
* @post User is authenticated and redirected on success.
*/
async function handleLogin() {
if (!username || !password) {
error = 'Please enter both username and password';
return;
}
loading = true;
error = '';
try {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
});
if (response.ok) {
const data = await response.json();
auth.setToken(data.access_token);
// Fetch user profile
const profileRes = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${data.access_token}`
}
});
if (profileRes.ok) {
const user = await profileRes.json();
auth.setUser(user);
goto('/');
} else {
error = 'Failed to fetch user profile';
}
} else {
const errData = await response.json();
error = errData.detail || 'Invalid username or password';
}
} catch (e) {
error = 'An error occurred during login';
console.error(e);
} finally {
loading = false;
}
}
// [/DEF:handleLogin:Function]
// [DEF:handleADFSLogin:Function]
/**
* @purpose Redirects the user to the ADFS login endpoint.
*/
function handleADFSLogin() {
window.location.href = '/api/auth/login/adfs';
}
// [/DEF:handleADFSLogin:Function]
onMount(() => {
if ($auth.isAuthenticated) {
goto('/');
}
});
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-center">Login</h2>
{#if error}
<div class="mb-4 p-3 bg-red-100 text-red-700 rounded border border-red-200">
{error}
</div>
{/if}
<form on:submit|preventDefault={handleLogin} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
id="username"
bind:value={username}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
bind:value={password}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div class="mt-6">
<button
on:click={handleADFSLogin}
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Corporate SSO (ADFS)
</button>
</div>
</div>
</div>
<!-- [/SECTION: TEMPLATE] -->
<style>
/* No additional styles needed, using Tailwind */
</style>
<!-- [/DEF:LoginPage:Component] -->

View File

@@ -18,6 +18,7 @@
import TaskHistory from '../../components/TaskHistory.svelte';
import TaskLogViewer from '../../components/TaskLogViewer.svelte';
import PasswordPrompt from '../../components/PasswordPrompt.svelte';
import { api } from '../../lib/api.js';
import { selectedTask } from '../../lib/stores.js';
import { resumeTask } from '../../services/taskService.js';
import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard';
@@ -58,9 +59,7 @@
*/
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
environments = await api.getEnvironmentsList();
} catch (e) {
error = e.message;
} finally {
@@ -78,9 +77,7 @@
*/
async function fetchDashboards(envId: string) {
try {
const response = await fetch(`/api/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
selectedDashboardIds = []; // Reset selection when env changes
} catch (e) {
error = e.message;
@@ -106,23 +103,17 @@
error = "";
try {
const [srcRes, tgtRes, mapRes, sugRes] = await Promise.all([
fetch(`/api/environments/${sourceEnvId}/databases`),
fetch(`/api/environments/${targetEnvId}/databases`),
fetch(`/api/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
fetch(`/api/mappings/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_env_id: sourceEnvId, target_env_id: targetEnvId })
})
const [src, tgt, maps, sugs] = await Promise.all([
api.requestApi(`/environments/${sourceEnvId}/databases`),
api.requestApi(`/environments/${targetEnvId}/databases`),
api.requestApi(`/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
api.postApi(`/mappings/suggest`, { source_env_id: sourceEnvId, target_env_id: targetEnvId })
]);
if (!srcRes.ok || !tgtRes.ok) throw new Error('Failed to fetch databases from environments');
sourceDatabases = await srcRes.json();
targetDatabases = await tgtRes.json();
mappings = await mapRes.json();
suggestions = await sugRes.json();
sourceDatabases = src;
targetDatabases = tgt;
mappings = maps;
suggestions = sugs;
} catch (e) {
error = e.message;
} finally {
@@ -145,22 +136,15 @@
if (!sDb || !tDb) return;
try {
const response = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
})
const savedMapping = await api.postApi('/mappings', {
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
});
if (!response.ok) throw new Error('Failed to save mapping');
const savedMapping = await response.json();
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
} catch (e) {
error = e.message;
@@ -253,14 +237,7 @@
replace_db_config: replaceDb
};
console.log(`[MigrationDashboard][Action] Starting migration with selection:`, selection);
const response = await fetch('/api/migration/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(selection)
});
console.log(`[MigrationDashboard][Action] API response status: ${response.status}`);
if (!response.ok) throw new Error(`Failed to start migration: ${response.status} ${response.statusText}`);
const result = await response.json();
const result = await api.postApi('/migration/execute', selection);
console.log(`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`);
// Wait a brief moment for the backend to ensure the task is retrievable
@@ -268,23 +245,18 @@
// Fetch full task details and switch to TaskRunner view
try {
const taskRes = await fetch(`/api/tasks/${result.task_id}`);
if (taskRes.ok) {
const task = await taskRes.json();
selectedTask.set(task);
} else {
// Fallback: create a temporary task object to switch view immediately
console.warn("Could not fetch task details immediately, using placeholder.");
selectedTask.set({
id: result.task_id,
plugin_id: 'superset-migration',
status: 'RUNNING',
logs: [],
params: {}
});
}
const task = await api.getTask(result.task_id);
selectedTask.set(task);
} catch (fetchErr) {
console.error("Failed to fetch new task details:", fetchErr);
// Fallback: create a temporary task object to switch view immediately
console.warn("Could not fetch task details immediately, using placeholder.");
selectedTask.set({
id: result.task_id,
plugin_id: 'superset-migration',
status: 'RUNNING',
logs: [],
params: {}
});
}
} catch (e) {
console.error(`[MigrationDashboard][Failure] Migration failed:`, e);
@@ -331,7 +303,7 @@
/>
</div>
<!-- [DEF:DashboardSelectionSection] -->
<!-- [DEF:DashboardSelectionSection:Component] -->
<div class="mb-8">
<h2 class="text-lg font-medium mb-4">Select Dashboards</h2>
@@ -344,7 +316,7 @@
<p class="text-gray-500 italic">Select a source environment to view dashboards.</p>
{/if}
</div>
<!-- [/DEF:DashboardSelectionSection] -->
<!-- [/DEF:DashboardSelectionSection:Component] -->
<div class="flex items-center mb-4">

View File

@@ -12,6 +12,7 @@
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { api } from '../../../lib/api.js';
import EnvSelector from '../../../components/EnvSelector.svelte';
import MappingTable from '../../../components/MappingTable.svelte';
import { t } from '$lib/i18n';
@@ -38,9 +39,7 @@
// @POST: environments array is populated.
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
environments = await api.getEnvironmentsList();
} catch (e) {
error = e.message;
} finally {
@@ -64,23 +63,17 @@
success = "";
try {
const [srcRes, tgtRes, mapRes, sugRes] = await Promise.all([
fetch(`/api/environments/${sourceEnvId}/databases`),
fetch(`/api/environments/${targetEnvId}/databases`),
fetch(`/api/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
fetch(`/api/mappings/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_env_id: sourceEnvId, target_env_id: targetEnvId })
})
const [src, tgt, maps, sugs] = await Promise.all([
api.requestApi(`/environments/${sourceEnvId}/databases`),
api.requestApi(`/environments/${targetEnvId}/databases`),
api.requestApi(`/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
api.postApi(`/mappings/suggest`, { source_env_id: sourceEnvId, target_env_id: targetEnvId })
]);
if (!srcRes.ok || !tgtRes.ok) throw new Error('Failed to fetch databases from environments');
sourceDatabases = await srcRes.json();
targetDatabases = await tgtRes.json();
mappings = await mapRes.json();
suggestions = await sugRes.json();
sourceDatabases = src;
targetDatabases = tgt;
mappings = maps;
suggestions = sugs;
} catch (e) {
error = e.message;
} finally {
@@ -103,22 +96,15 @@
if (!sDb || !tDb) return;
try {
const response = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
})
const savedMapping = await api.postApi('/mappings', {
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
});
if (!response.ok) throw new Error('Failed to save mapping');
const savedMapping = await response.json();
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
success = "Mapping saved successfully";
} catch (e) {

View File

@@ -8,9 +8,29 @@
/** @type {import('./$types').PageData} */
export let data;
let settings = data.settings;
let settings = data.settings || {
environments: [],
settings: {
storage: {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
}
}
};
$: settings = data.settings;
$: if (data.settings) {
settings = { ...data.settings };
if (settings.settings && !settings.settings.storage) {
settings.settings.storage = {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
};
}
}
let newEnv = {
id: '',

View File

@@ -18,7 +18,13 @@ export async function load() {
settings: {
environments: [],
settings: {
default_environment_id: null
default_environment_id: null,
storage: {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
}
}
},
error: 'Failed to load settings'

View File

@@ -1,8 +1,9 @@
<!-- [DEF:StoragePage:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: storage, files, management
@PURPOSE: Main page for file storage management.
@LAYER: Feature
@LAYER: UI
@RELATION: DEPENDS_ON -> storageService
@RELATION: CONTAINS -> FileList
@RELATION: CONTAINS -> FileUpload

View File

@@ -0,0 +1,241 @@
// [DEF:adminService:Module]
//
// @TIER: STANDARD
// @SEMANTICS: admin, users, roles, ad-mappings, api
// @PURPOSE: Service for Admin-related API calls (User and Role management).
// @LAYER: Service
// @RELATION: DEPENDS_ON -> frontend.src.lib.api
//
// @INVARIANT: All requests must include valid Admin JWT token (handled by api client).
// [SECTION: IMPORTS]
import { api } from '../lib/api';
// [/SECTION]
// [DEF:getUsers:Function]
/**
* @purpose Fetches all registered users from the backend.
* @pre User must be authenticated with Admin privileges.
* @post Returns an array of user objects.
* @returns {Promise<Array>}
* @relation CALLS -> backend.src.api.routes.admin.list_users
*/
async function getUsers() {
console.log('[getUsers][Entry]');
try {
const users = await api.requestApi('/admin/users', 'GET');
console.log('[getUsers][Coherence:OK]');
return users;
} catch (e) {
console.error('[getUsers][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getUsers:Function]
// [DEF:createUser:Function]
/**
* @purpose Creates a new local user.
* @pre User must be authenticated with Admin privileges.
* @param {Object} userData - User details (username, email, password, roles, is_active).
* @post New user record created in auth.db.
* @returns {Promise<Object>}
* @relation CALLS -> backend.src.api.routes.admin.create_user
*/
async function createUser(userData) {
console.log('[createUser][Entry]');
try {
const user = await api.postApi('/admin/users', userData);
console.log('[createUser][Coherence:OK]');
return user;
} catch (e) {
console.error('[createUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createUser:Function]
// [DEF:getRoles:Function]
/**
* @purpose Fetches all available system roles.
* @returns {Promise<Array>}
* @relation CALLS -> backend.src.api.routes.admin.list_roles
*/
async function getRoles() {
console.log('[getRoles][Entry]');
try {
const roles = await api.requestApi('/admin/roles', 'GET');
console.log('[getRoles][Coherence:OK]');
return roles;
} catch (e) {
console.error('[getRoles][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getRoles:Function]
// [DEF:getADGroupMappings:Function]
/**
* @purpose Fetches mappings between AD groups and local roles.
* @returns {Promise<Array>}
*/
async function getADGroupMappings() {
console.log('[getADGroupMappings][Entry]');
try {
const mappings = await api.requestApi('/admin/ad-mappings', 'GET');
console.log('[getADGroupMappings][Coherence:OK]');
return mappings;
} catch (e) {
console.error('[getADGroupMappings][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getADGroupMappings:Function]
// [DEF:createADGroupMapping:Function]
/**
* @purpose Creates or updates an AD group to Role mapping.
* @param {Object} mappingData - Mapping details (ad_group, role_id).
* @returns {Promise<Object>}
*/
async function createADGroupMapping(mappingData) {
console.log('[createADGroupMapping][Entry]');
try {
const mapping = await api.postApi('/admin/ad-mappings', mappingData);
console.log('[createADGroupMapping][Coherence:OK]');
return mapping;
} catch (e) {
console.error('[createADGroupMapping][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createADGroupMapping:Function]
// [DEF:updateUser:Function]
/**
* @purpose Updates an existing user.
* @param {string} userId - Target user ID.
* @param {Object} userData - Updated user data.
* @returns {Promise<Object>}
*/
async function updateUser(userId, userData) {
console.log('[updateUser][Entry]');
try {
const user = await api.requestApi(`/admin/users/${userId}`, 'PUT', userData);
console.log('[updateUser][Coherence:OK]');
return user;
} catch (e) {
console.error('[updateUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:updateUser:Function]
// [DEF:deleteUser:Function]
/**
* @purpose Deletes a user.
* @param {string} userId - Target user ID.
* @returns {Promise<void>}
*/
async function deleteUser(userId) {
console.log('[deleteUser][Entry]');
try {
await api.requestApi(`/admin/users/${userId}`, 'DELETE');
console.log('[deleteUser][Coherence:OK]');
} catch (e) {
console.error('[deleteUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:deleteUser:Function]
// [DEF:createRole:Function]
/**
* @purpose Creates a new role.
* @param {Object} roleData - Role details (name, description, permissions).
* @returns {Promise<Object>}
*/
async function createRole(roleData) {
console.log('[createRole][Entry]');
try {
const role = await api.postApi('/admin/roles', roleData);
console.log('[createRole][Coherence:OK]');
return role;
} catch (e) {
console.error('[createRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createRole:Function]
// [DEF:updateRole:Function]
/**
* @purpose Updates an existing role.
* @param {string} roleId - Target role ID.
* @param {Object} roleData - Updated role data.
* @returns {Promise<Object>}
*/
async function updateRole(roleId, roleData) {
console.log('[updateRole][Entry]');
try {
const role = await api.requestApi(`/admin/roles/${roleId}`, 'PUT', roleData);
console.log('[updateRole][Coherence:OK]');
return role;
} catch (e) {
console.error('[updateRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:updateRole:Function]
// [DEF:deleteRole:Function]
/**
* @purpose Deletes a role.
* @param {string} roleId - Target role ID.
* @returns {Promise<void>}
*/
async function deleteRole(roleId) {
console.log('[deleteRole][Entry]');
try {
await api.requestApi(`/admin/roles/${roleId}`, 'DELETE');
console.log('[deleteRole][Coherence:OK]');
} catch (e) {
console.error('[deleteRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:deleteRole:Function]
// [DEF:getPermissions:Function]
/**
* @purpose Fetches all available permissions.
* @returns {Promise<Array>}
*/
async function getPermissions() {
console.log('[getPermissions][Entry]');
try {
const permissions = await api.requestApi('/admin/permissions', 'GET');
console.log('[getPermissions][Coherence:OK]');
return permissions;
} catch (e) {
console.error('[getPermissions][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getPermissions:Function]
export const adminService = {
getUsers,
createUser,
updateUser,
deleteUser,
getRoles,
createRole,
updateRole,
deleteRole,
getPermissions,
getADGroupMappings,
createADGroupMapping
};
// [/DEF:adminService:Module]

View File

@@ -2,7 +2,9 @@
* Service for interacting with the Connection Management API.
*/
const API_BASE = '/api/settings/connections';
import { requestApi } from '../lib/api';
const API_BASE = '/settings/connections';
// [DEF:getConnections:Function]
/* @PURPOSE: Fetch a list of saved connections.
@@ -14,11 +16,7 @@ const API_BASE = '/api/settings/connections';
* @returns {Promise<Array>} List of connections.
*/
export async function getConnections() {
const response = await fetch(API_BASE);
if (!response.ok) {
throw new Error(`Failed to fetch connections: ${response.statusText}`);
}
return await response.json();
return requestApi(API_BASE);
}
// [/DEF:getConnections:Function]
@@ -33,19 +31,7 @@ export async function getConnections() {
* @returns {Promise<Object>} The created connection instance.
*/
export async function createConnection(connectionData) {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(connectionData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to create connection: ${response.statusText}`);
}
return await response.json();
return requestApi(API_BASE, 'POST', connectionData);
}
// [/DEF:createConnection:Function]
@@ -59,12 +45,6 @@ export async function createConnection(connectionData) {
* @param {string} connectionId - The ID of the connection to delete.
*/
export async function deleteConnection(connectionId) {
const response = await fetch(`${API_BASE}/${connectionId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete connection: ${response.statusText}`);
}
return requestApi(`${API_BASE}/${connectionId}`, 'DELETE');
}
// [/DEF:deleteConnection:Function]

View File

@@ -6,7 +6,9 @@
* @RELATION: DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md
*/
const API_BASE = '/api/git';
import { requestApi } from '../lib/api';
const API_BASE = '/git';
// [DEF:gitService:Action]
export const gitService = {
@@ -19,9 +21,7 @@ export const gitService = {
*/
async getConfigs() {
console.log('[getConfigs][Action] Fetching Git configs');
const response = await fetch(`${API_BASE}/config`);
if (!response.ok) throw new Error('Failed to fetch Git configs');
return response.json();
return requestApi(`${API_BASE}/config`);
},
/**
@@ -34,13 +34,7 @@ export const gitService = {
*/
async createConfig(config) {
console.log('[createConfig][Action] Creating Git config');
const response = await fetch(`${API_BASE}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) throw new Error('Failed to create Git config');
return response.json();
return requestApi(`${API_BASE}/config`, 'POST', config);
},
/**
@@ -53,11 +47,7 @@ export const gitService = {
*/
async deleteConfig(configId) {
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
const response = await fetch(`${API_BASE}/config/${configId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete Git config');
return response.json();
return requestApi(`${API_BASE}/config/${configId}`, 'DELETE');
},
/**
@@ -70,12 +60,7 @@ export const gitService = {
*/
async testConnection(config) {
console.log('[testConnection][Action] Testing Git connection');
const response = await fetch(`${API_BASE}/config/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return response.json();
return requestApi(`${API_BASE}/config/test`, 'POST', config);
},
/**
@@ -90,16 +75,10 @@ export const gitService = {
*/
async initRepository(dashboardId, configId, remoteUrl) {
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_id: configId, remote_url: remoteUrl })
return requestApi(`${API_BASE}/repositories/${dashboardId}/init`, 'POST', {
config_id: configId,
remote_url: remoteUrl
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to initialize repository');
}
return response.json();
},
/**
@@ -112,9 +91,7 @@ export const gitService = {
*/
async getBranches(dashboardId) {
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`);
if (!response.ok) throw new Error('Failed to fetch branches');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`);
},
/**
@@ -129,13 +106,10 @@ export const gitService = {
*/
async createBranch(dashboardId, name, fromBranch) {
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, from_branch: fromBranch })
return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`, 'POST', {
name,
from_branch: fromBranch
});
if (!response.ok) throw new Error('Failed to create branch');
return response.json();
},
/**
@@ -149,13 +123,7 @@ export const gitService = {
*/
async checkoutBranch(dashboardId, name) {
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) throw new Error('Failed to checkout branch');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/checkout`, 'POST', { name });
},
/**
@@ -170,13 +138,7 @@ export const gitService = {
*/
async commit(dashboardId, message, files) {
console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, files })
});
if (!response.ok) throw new Error('Failed to commit changes');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/commit`, 'POST', { message, files });
},
/**
@@ -189,11 +151,7 @@ export const gitService = {
*/
async push(dashboardId) {
console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/push`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to push changes');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/push`, 'POST');
},
/**
@@ -206,11 +164,7 @@ export const gitService = {
*/
async pull(dashboardId) {
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/pull`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to pull changes');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/pull`, 'POST');
},
/**
@@ -221,9 +175,7 @@ export const gitService = {
*/
async getEnvironments() {
console.log('[getEnvironments][Action] Fetching environments');
const response = await fetch(`${API_BASE}/environments`);
if (!response.ok) throw new Error('Failed to fetch environments');
return response.json();
return requestApi(`${API_BASE}/environments`);
},
/**
@@ -237,13 +189,9 @@ export const gitService = {
*/
async deploy(dashboardId, environmentId) {
console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: environmentId })
return requestApi(`${API_BASE}/repositories/${dashboardId}/deploy`, 'POST', {
environment_id: environmentId
});
if (!response.ok) throw new Error('Failed to deploy dashboard');
return response.json();
},
/**
@@ -255,9 +203,7 @@ export const gitService = {
*/
async getHistory(dashboardId, limit = 50) {
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`);
if (!response.ok) throw new Error('Failed to fetch commit history');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`);
},
/**
@@ -269,17 +215,9 @@ export const gitService = {
*/
async sync(dashboardId, sourceEnvId = null) {
console.log(`[sync][Action] Syncing dashboard ${dashboardId}`);
const url = new URL(`${window.location.origin}${API_BASE}/repositories/${dashboardId}/sync`);
if (sourceEnvId) url.searchParams.append('source_env_id', sourceEnvId);
const response = await fetch(url, {
method: 'POST'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to sync dashboard');
}
return response.json();
let endpoint = `${API_BASE}/repositories/${dashboardId}/sync`;
if (sourceEnvId) endpoint += `?source_env_id=${sourceEnvId}`;
return requestApi(endpoint, 'POST');
},
/**
@@ -292,9 +230,7 @@ export const gitService = {
*/
async getStatus(dashboardId) {
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/status`);
if (!response.ok) throw new Error('Failed to fetch status');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/status`);
},
/**
@@ -309,15 +245,12 @@ export const gitService = {
*/
async getDiff(dashboardId, filePath = null, staged = false) {
console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`);
let url = `${API_BASE}/repositories/${dashboardId}/diff`;
let endpoint = `${API_BASE}/repositories/${dashboardId}/diff`;
const params = new URLSearchParams();
if (filePath) params.append('file_path', filePath);
if (staged) params.append('staged', 'true');
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch diff');
return response.json();
if (params.toString()) endpoint += `?${params.toString()}`;
return requestApi(endpoint);
}
};
// [/DEF:gitService:Action]

View File

@@ -2,7 +2,9 @@
* Service for interacting with the Task Management API.
*/
const API_BASE = '/api/tasks';
import { requestApi } from '../lib/api';
const API_BASE = '/tasks';
// [DEF:getTasks:Function]
/* @PURPOSE: Fetch a list of tasks with pagination and optional status filter.
@@ -25,11 +27,7 @@ export async function getTasks(limit = 10, offset = 0, status = null) {
params.append('status', status);
}
const response = await fetch(`${API_BASE}?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch tasks: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}?${params.toString()}`);
}
// [/DEF:getTasks:Function]
@@ -44,11 +42,7 @@ export async function getTasks(limit = 10, offset = 0, status = null) {
* @returns {Promise<Object>} Task details.
*/
export async function getTask(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}`);
}
// [/DEF:getTask:Function]
@@ -86,19 +80,7 @@ export async function getTaskLogs(taskId) {
* @returns {Promise<Object>} Updated task object.
*/
export async function resumeTask(taskId, passwords) {
const response = await fetch(`${API_BASE}/${taskId}/resume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ passwords })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resume task: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}/resume`, 'POST', { passwords });
}
// [/DEF:resumeTask:Function]
@@ -114,19 +96,7 @@ export async function resumeTask(taskId, passwords) {
* @returns {Promise<Object>} Updated task object.
*/
export async function resolveTask(taskId, resolutionParams) {
const response = await fetch(`${API_BASE}/${taskId}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ resolution_params: resolutionParams })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resolve task: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}/resolve`, 'POST', { resolution_params: resolutionParams });
}
// [/DEF:resolveTask:Function]
@@ -145,12 +115,6 @@ export async function clearTasks(status = null) {
params.append('status', status);
}
const response = await fetch(`${API_BASE}?${params.toString()}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to clear tasks: ${response.statusText}`);
}
return requestApi(`${API_BASE}?${params.toString()}`, 'DELETE');
}
// [/DEF:clearTasks:Function]

View File

@@ -2,7 +2,9 @@
* Service for generic Task API communication used by Tools.
*/
const API_BASE = '/api/tasks';
import { requestApi } from '../lib/api';
const API_BASE = '/tasks';
// [DEF:runTask:Function]
/* @PURPOSE: Start a new task for a given plugin.
@@ -16,19 +18,7 @@ const API_BASE = '/api/tasks';
* @returns {Promise<Object>} The created task instance.
*/
export async function runTask(pluginId, params) {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ plugin_id: pluginId, params })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to start task: ${response.statusText}`);
}
return await response.json();
return requestApi(API_BASE, 'POST', { plugin_id: pluginId, params });
}
// [/DEF:runTask:Function]
@@ -43,10 +33,6 @@ export async function runTask(pluginId, params) {
* @returns {Promise<Object>} Task details.
*/
export async function getTaskStatus(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}`);
}
// [/DEF:getTaskStatus:Function]

File diff suppressed because it is too large Load Diff

View File

@@ -1,245 +1,72 @@
РОЛЬ: Архитектор Семантической Когерентности.
ЗАДАЧА: Генерация кода (Python/Svelte).
РЕЖИМ: Строгий. Детерминированный. Без болтовни.
# SYSTEM STANDARD: POLYGLOT CODE GENERATION PROTOCOL (GRACE-Poly)
I. ЗАКОН (АКСИОМЫ)
1. Смысл первичен. Код вторичен.
2. Контракт (@PRE/@POST) — источник истины.
3. Структура `[DEF]...[/DEF]` — нерушима.
4. Архитектура в Header — неизменяема.
5. Сложность фрактала ограничена: модуль < 300 строк.
**OBJECTIVE:** Generate Python and Svelte/TypeScript code that strictly adheres to Semantic Coherence standards. Output must be machine-readable, fractal-structured, and optimized for Sparse Attention navigation.
II. СИНТАКСИС (ЖЕСТКИЙ ФОРМАТ)
ЯКОРЬ (Контейнер):
Начало: `# [DEF:id:Type]` (Python) | `<!-- [DEF:id:Type] -->` (Svelte)
Конец: `# [/DEF:id:Type]` (Python) | `<!-- [/DEF:id:Type] -->` (Svelte) (ОБЯЗАТЕЛЬНО для аккумуляции)
Типы: Module, Class, Function, Component, Store.
## I. CORE REQUIREMENTS
1. **Causal Validity:** Semantic definitions (Contracts) must ALWAYS precede implementation code.
2. **Immutability:** Architectural decisions defined in the Module/Component Header are treated as immutable constraints.
3. **Format Compliance:** Output must strictly follow the `[DEF:..:...]` / `[/DEF:...:...]` anchor syntax for structure.
4. **Logic over Assertion:** Contracts define the *logic flow*. Do not generate explicit `assert` statements unless requested. The code logic itself must inherently satisfy the Pre/Post conditions (e.g., via control flow, guards, or types).
5. **Fractal Complexity:** Modules and functions must adhere to strict size limits (~300 lines/module, ~30-50 lines/function) to maintain semantic focus.
ТЕГ (Метаданные):
Вид: `# @KEY: Value` (внутри DEF, до кода).
---
ГРАФ (Связи):
Вид: `# @RELATION: PREDICATE -> TARGET_ID`
Предикаты: DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES.
## II. SYNTAX SPECIFICATION
III. СТРУКТУРА ФАЙЛА
1. HEADER (Всегда первый):
[DEF:filename:Module]
@TIER: [CRITICAL|STANDARD|TRIVIAL] (Дефолт: STANDARD)
@SEMANTICS: [keywords]
@PURPOSE: [Главная цель]
@LAYER: [Domain/UI/Infra]
@RELATION: [Зависимости]
@INVARIANT: [Незыблемое правило]
2. BODY: Импорты -> Реализация.
3. FOOTER: [/DEF:filename]
Code structure is defined by **Anchors** (square brackets). Metadata is defined by **Tags** (native comment style).
IV. КОНТРАКТ (DBC)
Расположение: Внутри [DEF], ПЕРЕД кодом.
Стиль Python: Комментарии `# @TAG`.
Стиль Svelte: JSDoc `/** @tag */`.
### 1. Entity Anchors (The "Container")
Used to define the boundaries of Modules, Classes, Components, and Functions.
Теги:
@PURPOSE: Суть (High Entropy).
@PRE: Входные условия.
@POST: Гарантии выхода.
@SIDE_EFFECT: Мутации, IO.
* **Python:**
* Start: `# [DEF:identifier:Type]`
* End: `# [/DEF:identifier:Type]`
* **Svelte (Top-level):**
* Start: `<!-- [DEF:ComponentName:Component] -->`
* End: `<!-- [/DEF:ComponentName:Component] -->`
* **Svelte (Script/JS/TS):**
* Start: `// [DEF:funcName:Function]`
* End: `// [/DEF:funcName:Function]`
V. АДАПТАЦИЯ (TIERS)
Определяется тегом `@TIER` в Header.
**Types:** `Module`, `Component`, `Class`, `Function`, `Store`, `Action`.
1. CRITICAL (Core/Security):
- Требование: Полный контракт, Граф (@RELATION), Инварианты (@INVARIANT), Строгие Логи.
2. STANDARD (BizLogic/UI):
- Требование: Базовый контракт (@PURPOSE), Логи, @RELATION (если есть связи).
3. TRIVIAL (DTO/Utils):
- Требование: Только Якоря [DEF] и @PURPOSE. Логи и Граф не обязательны.
### 2. Graph Relations (The "Map")
Defines high-level dependencies.
* **Python Syntax:** `# @RELATION: TYPE -> TARGET_ID`
* **Svelte/JS Syntax:** `// @RELATION: TYPE -> TARGET_ID`
* **Types:** `DEPENDS_ON`, `CALLS`, `INHERITS_FROM`, `IMPLEMENTS`, `BINDS_TO`, `DISPATCHES`.
VI. ЛОГИРОВАНИЕ (BELIEF STATE)
Цель: Трассировка для самокоррекции.
Python: Context Manager `with belief_scope("ID"):`.
Svelte: `console.log("[ID][STATE] Msg")`.
Состояния: Entry -> Action -> Coherence:OK / Failed -> Exit.
---
VII. АЛГОРИТМ ГЕНЕРАЦИИ
1. АНАЛИЗ. Оцени TIER и слой.
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту.
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
## III. FILE STRUCTURE STANDARD
### 1. Python Module Header (`.py`)
```python
# [DEF:module_name:Module]
#
# @SEMANTICS: [keywords for vector search]
# @PURPOSE: [Primary responsibility of the module]
# @LAYER: [Domain/Infra/API]
# @RELATION: [Dependencies]
#
# @INVARIANT: [Global immutable rule]
# @CONSTRAINT: [Hard restriction, e.g., "No ORM calls here"]
# [SECTION: IMPORTS]
...
# [/SECTION]
# ... IMPLEMENTATION ...
# [/DEF:module_name:Module]
```
### 2. Svelte Component Header (`.svelte`)
```html
<!-- [DEF:ComponentName:Component] -->
<!--
@SEMANTICS: [keywords]
@PURPOSE: [Primary UI responsibility]
@LAYER: [Feature/Atom/Layout]
@RELATION: [Child components, Stores]
@INVARIANT: [UI rules, e.g., "Always responsive"]
-->
<script lang="ts">
// [SECTION: IMPORTS]
// ...
// [/SECTION: IMPORTS]
// ... LOGIC IMPLEMENTATION ...
</script>
<!-- [SECTION: TEMPLATE] -->
...
<!-- [/SECTION: TEMPLATE] -->
<style>
/* ... */
</style>
<!-- [/DEF:ComponentName:Component] -->
```
---
## IV. CONTRACTS (Design by Contract & Semantic Control)
Contracts are the **Source of Truth** and the **Control Vector** for the code. They must be written with high **semantic density** to ensure the LLM fully "understands" the function's role within the larger Graph without needing to read the implementation body.
### 1. The Anatomy of a Semantic Contract
Every contract must answer three questions for the AI Agent:
1. **Intent:** *Why* does this exist? (Vector alignment)
2. **Boundaries:** *What* are the constraints? (Pre/Post/Invariants)
3. **Dynamics:** *How* does it change the system state? (Side Effects/Graph)
#### Standard Tags Taxonomy:
* `@PURPOSE`: (**Mandatory**) A concise, high-entropy summary of functionality.
* `@PRE`: (**Mandatory**) Conditions required *before* execution. Defines the valid input space.
* `@POST`: (**Mandatory**) Conditions guaranteed *after* execution. Defines the valid output space.
* `@PARAM`: Input definitions with strict typing.
* `@RETURN`: Output definition.
* `@THROW`: Explicit failure modes.
* `@SIDE_EFFECT`: (**Critical**) Explicitly lists external state mutations (DB writes, UI updates, events). Vital for "Mental Modeling".
* `@INVARIANT`: (**Optional**) Local rules that hold true throughout the function execution.
* `@ALGORITHM`: (**Optional**) For complex logic, briefly describes the strategy (e.g., "Two-pointer approach", "Retry with exponential backoff").
* `@RELATION`: (**Graph**) Edges to other nodes (`CALLS`, `DISPATCHES`, `DEPENDS_ON`).
---
### 2. Python Contract Style (`.py`)
Uses structured comment blocks inside the anchor. Focuses on type hints and logic flow guards.
```python
# [DEF:process_order_batch:Function]
# @PURPOSE: Orchestrates the validation and processing of a batch of orders.
# Ensures atomic processing per order (failure of one does not stop others).
#
# @PRE: batch_id must be a valid UUID string.
# @PRE: orders list must not be empty.
# @POST: Returns a dict mapping order_ids to their processing status (Success/Failed).
# @INVARIANT: The length of the returned dict must equal the length of input orders.
#
# @PARAM: batch_id (str) - The unique identifier for the batch trace.
# @PARAM: orders (List[OrderDTO]) - List of immutable order objects.
# @RETURN: Dict[str, OrderStatus] - Result map.
#
# @SIDE_EFFECT: Writes audit logs to DB.
# @SIDE_EFFECT: Publishes 'ORDER_PROCESSED' event to MessageBus.
#
# @RELATION: CALLS -> InventoryService.reserve_items
# @RELATION: CALLS -> PaymentGateway.authorize
# @RELATION: WRITES_TO -> Database.AuditLog
def process_order_batch(batch_id: str, orders: List[OrderDTO]) -> Dict[str, OrderStatus]:
# 1. Structural Guard Logic (Handling @PRE)
if not orders:
return {}
# 2. Implementation with @INVARIANT in mind
results = {}
for order in orders:
# ... logic ...
pass
# 3. Completion (Logic naturally satisfies @POST)
return results
# [/DEF:process_order_batch:Function]
```
### 3. Svelte/JS Contract Style (JSDoc++)
Uses enhanced JSDoc. Since JS is less strict than Python, the contract acts as a strict typing and behavioral guard.
```javascript
// [DEF:handleUserLogin:Function]
/**
* @purpose Authenticates the user and synchronizes the local UI state.
* Handles the complete lifecycle from form submission to redirection.
*
* @pre LoginForm must be valid (validated by UI constraints).
* @pre Network must be available (optimistic check).
* @post SessionStore contains a valid JWT token.
* @post User is redirected to the Dashboard.
*
* @param {LoginCredentials} credentials - Email and password object.
* @returns {Promise<void>}
* @throws {NetworkError} If API is unreachable.
* @throws {AuthError} If credentials are invalid (401).
*
* @side_effect Updates global $session store.
* @side_effect Clears any existing error toasts.
*
* @algorithm 1. Set loading state -> 2. API Call -> 3. Decode Token -> 4. Update Store -> 5. Redirect.
*/
// @RELATION: CALLS -> api.auth.login
// @RELATION: MODIFIES_STATE_OF -> stores.session
// @RELATION: DISPATCHES -> 'toast:success'
async function handleUserLogin(credentials) {
// 1. Guard Clause (@PRE)
if (!isValid(credentials)) return;
try {
// ... logic ...
} catch (e) {
// Error handling (@THROW)
}
}
// [/DEF:handleUserLogin:Function]
```
---
### 4. Semantic Rules for Contracts
1. **Completeness:** A developer (or Agent) must be able to write the function body *solely* by reading the Contract, without guessing.
2. **No Implementation Leakage:** Describe *what* happens, not *how* (unless using `@ALGORITHM` for complexity reasons). E.g., say "Persists user" instead of "Inserts into users table via SQL".
3. **Active Voice:** Use active verbs (`Calculates`, `Updates`, `Enforces`) to stronger vector alignment.
4. **Graph Connectivity:** The `@RELATION` tags must explicitly link to other `[DEF:...]` IDs existing in the codebase. This builds the navigation graph for RAG.
---
## V. LOGGING STANDARD (BELIEF STATE)
Logs delineate the agent's internal state.
* **Python:** MUST use a Context Manager (e.g., `with belief_scope("ANCHOR_ID"):`) to ensure state consistency and automatic handling of Entry/Exit/Error states.
* Manual logging (inside scope): `logger.info(f"[{ANCHOR_ID}][{STATE}] Msg")`
* **Svelte/JS:** `console.log(\`[${ANCHOR_ID}][${STATE}] Msg\`)`
**Required States:**
1. `Entry` (Start of block - Auto-logged by Context Manager)
2. `Action` (Key business logic - Manual log)
3. `Coherence:OK` (Logic successfully completed - Auto-logged by Context Manager)
4. `Coherence:Failed` (Exception/Error - Auto-logged by Context Manager)
5. `Exit` (End of block - Auto-logged by Context Manager)
---
## VI. FRACTAL COMPLEXITY LIMIT
To maintain semantic coherence and avoid "Attention Sink" issues:
* **Module Size:** If a Module body exceeds ~300 lines (or logical complexity), it MUST be refactored into sub-modules or a package structure.
* **Function Size:** Functions should fit within a standard attention "chunk" (approx. 30-50 lines). If larger, logic MUST be decomposed into helper functions with their own contracts.
This ensures every vector embedding remains sharp and focused.
---
## VII. GENERATION WORKFLOW
1. **Context Analysis:** Identify language (Python vs Svelte) and Architecture Layer.
2. **Scaffolding:** Generate the `[DEF:...:...]` Anchors and Header/Contract **before** writing any logic.
3. **Implementation:** Write the code. Ensure the code logic handles the `@PRE` conditions (e.g., via `if/return` or guards) and satisfies `@POST` conditions naturally. **Do not write explicit `assert` statements unless debugging mode is requested.**
4. **Closure:** Ensure every `[DEF:...:...]` is closed with `[/DEF:...:...]` to accumulate semantic context.
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Multi-User Authentication and Authorization
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-26
**Feature**: [Link to spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,39 @@
# Security Requirements Checklist: Multi-User Auth
**Purpose**: Validate completeness and rigor of security requirements for authentication and authorization.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Authentication Security
- [x] CHK001 Are password complexity requirements specified for local users? [Completeness, Gap] (Covered by T037)
- [x] CHK002 Is the exact hashing algorithm (bcrypt) and work factor specified? [Clarity, Spec §Research] (Covered by T006)
- [x] CHK003 Are account lockout policies defined for failed login attempts? [Coverage, Gap] (Covered by T033)
- [x] CHK004 Is the behavior for inactive/disabled accounts explicitly defined for both local and ADFS users? [Edge Case, Spec §Edge Cases] (Covered by T044)
- [x] CHK005 Are requirements defined for session revocation (e.g., logout, admin action)? [Completeness] (Covered by T043)
## ADFS & SSO Security
- [x] CHK006 Are token validation requirements (signature, issuer, audience) specified for ADFS OIDC tokens? [Completeness] (Covered by T007)
- [x] CHK007 Is the mapping behavior defined when an ADFS user is removed from a mapped AD group? [Edge Case, Gap] (Covered by T028)
- [x] CHK008 Are requirements defined for handling ADFS token expiration and refresh? [Coverage] (Covered by T046)
- [x] CHK009 Is the JIT provisioning process secure against privilege escalation (e.g., default role)? [Security, Spec §FR-008] (Covered by T028)
## Authorization & RBAC
- [x] CHK010 Are "default deny" requirements specified for plugin access? [Clarity, Spec §SC-002] (Covered by T020)
- [x] CHK011 Is the behavior defined when a user has multiple roles with conflicting permissions? [Edge Case, Gap] (Covered by T045)
- [x] CHK012 Are requirements specified for preventing admins from removing their own admin privileges (lockout prevention)? [Edge Case] (Covered by T022)
- [x] CHK013 Is the scope of "Execute" vs "Read" permission clearly defined for each plugin? [Clarity] (Covered by T019)
## Data Protection
- [x] CHK014 Are requirements defined for protecting sensitive data (passwords, tokens) in logs? [Completeness, Spec §Constitution] (Covered by T047)
- [x] CHK015 Are HttpOnly and Secure flags required for session cookies? [Clarity, Spec §Research] (Covered by T032)
- [x] CHK016 Is the storage mechanism for ADFS client secrets defined securely? [Completeness] (Covered by T002)
## API Security
- [x] CHK017 Are authentication requirements enforced on ALL API endpoints (except login)? [Coverage] (Covered by T021)
- [x] CHK018 Are rate limiting requirements defined for login endpoints to prevent brute force? [Gap] (Covered by T033)
- [x] CHK019 Are error messages required to be generic to avoid username enumeration? [Clarity] (Covered by T034)

View File

@@ -0,0 +1,31 @@
# Technical Readiness Checklist: Multi-User Auth
**Purpose**: Validate technical specifications, schema, and API contracts.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Data Model & Schema
- [x] CHK001 Are all necessary fields defined for the `User` entity (e.g., last_login)? [Completeness, Spec §Data Model] (Covered by T004)
- [x] CHK002 Are foreign key constraints explicitly defined for `ADGroupMapping`? [Clarity, Spec §Data Model] (Covered by T027)
- [x] CHK003 Is the uniqueness constraint for `username` and `email` specified? [Consistency] (Covered by T004)
- [x] CHK004 Are database migration requirements defined for the new `auth.db`? [Completeness, Gap] (Covered by T005)
## API Contracts
- [x] CHK005 Are request/response schemas defined for the `login` endpoint? [Completeness, Spec §Contracts] (Covered by T009)
- [x] CHK006 Are error response codes (401, 403, 404) standardized across all auth endpoints? [Consistency] (Covered by T012)
- [x] CHK007 Is the structure of the JWT payload (claims) explicitly defined? [Clarity, Spec §Research] (Covered by T007)
- [x] CHK008 Are pagination requirements defined for the "List Users" admin endpoint? [Gap] (Covered by T023)
## Dependencies & Integration
- [x] CHK009 Are version requirements specified for `Authlib` and `Passlib`? [Clarity, Spec §Plan] (Covered by T001)
- [x] CHK010 Is the dependency on the existing `TaskManager` for plugin execution defined? [Integration] (Covered by T021)
- [x] CHK011 Are requirements defined for the CLI admin creation tool? [Completeness, Spec §FR-009] (Covered by T008)
## Non-Functional Requirements
- [x] CHK012 Is the maximum acceptable latency for auth verification specified? [Clarity, Spec §Plan] (Covered by T013)
- [x] CHK013 Are concurrency requirements defined for the SQLite `auth.db` (WAL mode)? [Completeness, Spec §Research] (Covered by T003)
- [x] CHK014 Are logging requirements defined for audit trails (who did what)? [Completeness] (Covered by T047)

View File

@@ -0,0 +1,26 @@
# Testing Requirements Checklist: Multi-User Auth
**Purpose**: Validate test scenario coverage and strategy.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Functional Coverage
- [x] CHK001 Are positive test scenarios defined for Local Login? [Coverage, Spec §US-1] (Covered by T049)
- [x] CHK002 Are positive test scenarios defined for ADFS Login (mocked)? [Coverage, Spec §US-3] (Covered by T050)
- [x] CHK003 Are negative test scenarios defined for invalid passwords? [Coverage] (Covered by T049)
- [x] CHK004 Are negative test scenarios defined for unauthorized plugin access? [Coverage, Spec §US-2] (Covered by T049)
- [x] CHK005 Are test scenarios defined for switching between auth methods on the same screen? [Coverage] (Covered by T050)
## Edge Cases
- [x] CHK005 Are test scenarios defined for mixed-case username handling? [Edge Case] (Covered by T049)
- [x] CHK006 Are test scenarios defined for ADFS JIT provisioning with missing groups? [Edge Case] (Covered by T050)
- [x] CHK007 Are test scenarios defined for accessing the API with an expired token? [Edge Case] (Covered by T049)
- [x] CHK008 Are test scenarios defined for concurrent login sessions? [Edge Case] (Covered by T049)
## Integration & System
- [x] CHK009 Is the strategy defined for mocking ADFS during CI/CD tests? [Completeness] (Covered by T041)
- [x] CHK010 Are end-to-end tests required for the full admin user creation flow? [Coverage] (Covered by T050)
- [x] CHK011 Are tests required to verify the CLI admin creation tool? [Coverage] (Covered by T049)

View File

@@ -0,0 +1,31 @@
# UX Requirements Checklist: Multi-User Auth
**Purpose**: Validate user experience and interface requirements.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Login Flow
- [x] CHK001 Are feedback requirements defined for invalid credentials (generic message)? [Clarity, Spec §US-1] (Covered by T016)
- [x] CHK002 Is the redirect behavior specified after successful login (dashboard vs deep link)? [Clarity, Spec §US-1] (Covered by T016)
- [x] CHK003 Are loading states required during the ADFS redirection process? [Completeness] (Covered by T030)
- [x] CHK004 Is the "Session Expired" user flow defined? [Edge Case, Gap] (Covered by T035)
- [x] CHK005 Are requirements defined for the dual-mode login screen layout (Form + ADFS Button)? [Clarity, Spec §FR-013] (Covered by T030)
## Admin Interface
- [x] CHK005 Are requirements defined for the User Management list view (columns, sorting)? [Completeness] (Covered by T024)
- [x] CHK006 Is the feedback mechanism defined for successful/failed user creation? [Clarity] (Covered by T024)
- [x] CHK007 Are confirmation dialogs required for deleting users? [Safety, Gap] (Covered by T040)
- [x] CHK008 Is the UI behavior defined when assigning roles (dropdown, search)? [Clarity] (Covered by T024)
## Navigation & Visibility
- [x] CHK009 Are requirements defined for hiding menu items the user lacks permission for? [Completeness, Spec §FR-006] (Covered by T025)
- [x] CHK010 Is the behavior defined if a user tries to access a restricted URL directly? [Edge Case] (Covered by T042)
- [x] CHK011 Are user profile/logout controls required to be visible on all pages? [Consistency] (Covered by T025)
## Accessibility
- [x] CHK012 Are keyboard navigation requirements defined for the login form? [Coverage] (Covered by T048)
- [x] CHK013 Are error message accessibility requirements (ARIA alerts) specified? [Coverage] (Covered by T048)

View File

@@ -0,0 +1,132 @@
openapi: 3.0.0
info:
title: Authentication API
version: 1.0.0
paths:
/api/auth/login:
post:
summary: Login with username/password
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
username:
type: string
password:
type: string
responses:
'200':
description: Successful login
content:
application/json:
schema:
$ref: '#/components/schemas/Token'
'401':
description: Invalid credentials
/api/auth/login/adfs:
get:
summary: Initiate ADFS login flow
responses:
'302':
description: Redirect to ADFS provider
/api/auth/callback/adfs:
get:
summary: ADFS callback handler
parameters:
- in: query
name: code
schema:
type: string
required: true
responses:
'200':
description: Successful login via ADFS
content:
application/json:
schema:
$ref: '#/components/schemas/Token'
/api/auth/me:
get:
summary: Get current user profile
security:
- bearerAuth: []
responses:
'200':
description: User profile
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/api/admin/users:
get:
summary: List all users
security:
- bearerAuth: []
responses:
'200':
description: List of users
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
post:
summary: Create a new user
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserCreate'
responses:
'201':
description: User created
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Token:
type: object
properties:
access_token:
type: string
token_type:
type: string
User:
type: object
properties:
id:
type: string
username:
type: string
email:
type: string
roles:
type: array
items:
type: string
UserCreate:
type: object
properties:
username:
type: string
password:
type: string
roles:
type: array
items:
type: string

View File

@@ -0,0 +1,86 @@
# Data Model: Multi-User Authentication
## Entities
### User
Represents an identity that can authenticate to the system.
| Field | Type | Description | Constraints |
|-------|------|-------------|-------------|
| `id` | UUID | Unique identifier | Primary Key |
| `username` | String | Unique login name | Unique, Not Null |
| `email` | String | User email address | Unique, Optional |
| `password_hash` | String | Bcrypt hash of password | Nullable (if ADFS) |
| `auth_source` | Enum | Source of identity | `LOCAL` or `ADFS` |
| `is_active` | Boolean | Account status | Default `True` |
| `created_at` | DateTime | Timestamp of creation | Auto-generated |
| `last_login` | DateTime | Timestamp of last login | Nullable |
### Role
Represents a collection of permissions.
| Field | Type | Description | Constraints |
|-------|------|-------------|-------------|
| `id` | UUID | Unique identifier | Primary Key |
| `name` | String | Human-readable role name | Unique, Not Null |
| `description` | String | Description of role purpose | Optional |
### Permission
Represents a specific capability within the system.
| Field | Type | Description | Constraints |
|-------|------|-------------|-------------|
| `id` | UUID | Unique identifier | Primary Key |
| `resource` | String | Target resource (e.g. `plugin:backup`) | Not Null |
| `action` | Enum | Type of access | `READ`, `EXECUTE`, `WRITE` |
### ADGroupMapping
Maps an Active Directory group to a local System Role.
| Field | Type | Description | Constraints |
|-------|------|-------------|-------------|
| `id` | UUID | Unique identifier | Primary Key |
| `ad_group_name` | String | Name of the group in AD | Unique, Not Null |
| `role_id` | UUID | ID of the local role to assign | Foreign Key -> Role.id |
## Relationships
- **User <-> Role**: Many-to-Many (via `user_roles` table)
- A User can have multiple Roles.
- A Role can be assigned to multiple Users.
- **Role <-> Permission**: Many-to-Many (via `role_permissions` table)
- A Role is defined by a set of Permissions.
- A Permission can belong to multiple Roles.
## Storage Schema (SQLAlchemy)
```python
# Conceptual Schema Definition
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=generate_uuid)
username = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=True)
auth_source = Column(String, default="local")
is_active = Column(Boolean, default=True)
roles = relationship("Role", secondary="user_roles", back_populates="users")
class Role(Base):
__tablename__ = "roles"
id = Column(String, primary_key=True, default=generate_uuid)
name = Column(String, unique=True, nullable=False)
permissions = relationship("Permission", secondary="role_permissions")
users = relationship("User", secondary="user_roles", back_populates="roles")
class Permission(Base):
__tablename__ = "permissions"
id = Column(String, primary_key=True, default=generate_uuid)
resource = Column(String, nullable=False) # e.g., "plugin:backup"
action = Column(String, nullable=False) # e.g., "execute"
class ADGroupMapping(Base):
__tablename__ = "ad_group_mappings"
id = Column(String, primary_key=True, default=generate_uuid)
ad_group_name = Column(String, unique=True, nullable=False)
role_id = Column(String, ForeignKey("roles.id"), nullable=False)

View File

@@ -0,0 +1,103 @@
# Implementation Plan: Multi-User Authentication and Authorization
**Branch**: `016-multi-user-auth` | **Date**: 2026-01-26 | **Spec**: [`specs/016-multi-user-auth/spec.md`](spec.md)
**Input**: Feature specification from `specs/016-multi-user-auth/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
## Summary
Implement a robust authentication system supporting local users (username/password) and corporate SSO (ADFS via OIDC/OAuth2) simultaneously. The system will enforce Role-Based Access Control (RBAC) to restrict plugin access. Data will be persisted in a dedicated SQLite database (`auth.db`), and sessions will be managed via stateless JWTs. A CLI tool will be provided for initial admin provisioning. The login interface will provide dual options (Form + SSO Button) to ensure administrator access even during ADFS outages.
## Technical Context
**Language/Version**: Python 3.9+ (Backend), Node.js 18+ (Frontend)
**Primary Dependencies**:
- Backend: FastAPI, Authlib (ADFS/OIDC), Passlib[bcrypt] (Password hashing), PyJWT (Token management), SQLAlchemy (ORM for auth.db)
- Frontend: SvelteKit (UI), standard fetch API (JWT handling)
**Storage**: SQLite (`auth.db`) for Users, Roles, Permissions, and Mappings.
**Testing**: pytest (Backend), vitest/playwright (Frontend)
**Target Platform**: Linux server (Dockerized environment)
**Project Type**: Web Application (FastAPI Backend + SvelteKit Frontend)
**Performance Goals**: <100ms auth verification overhead per request.
**Constraints**: Must run in existing environment without external DB dependencies (hence SQLite).
**Scale/Scope**: ~10-100 concurrent users, ~5-10 distinct roles.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- [x] **I. Semantic Protocol Compliance**: All new modules will use `[DEF]` anchors and `@RELATION` tags.
- [x] **II. Causal Validity**: Contracts (OpenAPI/Pydantic models) will be defined before implementation.
- [x] **III. Immutability of Architecture**: No changes to existing core architecture invariants; adding a new `AuthModule` layer.
- [x] **IV. Design by Contract**: All auth functions will define `@PRE`/`@POST` conditions.
- [x] **V. Belief State Logging**: Auth events will be logged using the standard belief scope logger.
- [x] **VI. Fractal Complexity Limit**: Auth logic will be modularized (Service, Repository, API layers).
- [x] **VII. Everything is a Plugin**: While core auth is middleware, the *management* of users/roles will be exposed via a System Plugin or dedicated Admin API, respecting the modular design.
- [x] **VIII. Unified Frontend Experience**: Login and Admin UI will use standard Svelte components and i18n.
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
backend/
├── src/
│ ├── api/
│ │ ├── auth/ # New: Auth endpoints (login, logout, refresh)
│ │ ├── admin/ # New: Admin endpoints (users, roles)
│ │ └── dependencies.py # Update: Add get_current_user, get_current_active_user
│ ├── core/
│ │ ├── auth/ # New: Core auth logic
│ │ │ ├── jwt.py # Token handling
│ │ │ ├── security.py # Password hashing
│ │ │ └── config.py # Auth settings
│ │ └── database.py # Update: Support for multiple DBs (auth.db)
│ ├── models/
│ │ └── auth.py # New: SQLAlchemy models (User, Role, Permission)
│ ├── schemas/ # New: Pydantic schemas for Auth
│ │ └── auth.py
│ └── services/
│ └── auth_service.py # New: Auth business logic
└── tests/
└── auth/ # New: Auth tests
frontend/
├── src/
│ ├── lib/
│ │ ├── auth/ # New: Frontend auth stores/logic
│ │ └── api.js # Update: Add auth headers and export core methods
│ ├── services/
│ │ └── adminService.js # New: Service for admin API operations
│ ├── routes/
│ │ ├── login/ # New: Login page
│ │ └── admin/
│ │ ├── users/ # New: User Management UI
│ │ ├── roles/ # New: Role Management UI
│ │ └── settings/ # New: ADFS Configuration UI
│ └── components/
│ └── auth/ # New: Auth components (ProtectedRoute, Login form)
```
**Structure Decision**: Web application structure with separated backend (FastAPI) and frontend (SvelteKit). Auth logic is centralized in `backend/src/core/auth` and `backend/src/services`, with a new persistent store `auth.db`. Frontend will implement a reactive auth store.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View File

@@ -0,0 +1,54 @@
# Quickstart: Multi-User Auth
## Prerequisites
- Python 3.9+
- Node.js 18+
- Existing project environment
## Setup
1. **Install Dependencies**:
```bash
pip install "passlib[bcrypt]" "python-jose[cryptography]" "Authlib" "sqlalchemy"
```
2. **Initialize Database**:
Run the migration script to create `auth.db` and tables.
```bash
python backend/src/scripts/init_auth_db.py
```
3. **Create Admin User**:
Use the CLI tool to create the initial superuser.
```bash
python backend/src/scripts/create_admin.py --username admin --password securepassword
```
## Running the Application
1. **Start Backend**:
```bash
cd backend
uvicorn src.app:app --reload
```
2. **Start Frontend**:
```bash
cd frontend
npm run dev
```
3. **Login**:
Navigate to `http://localhost:5173/login` and use the admin credentials created above.
## Configuring ADFS
1. Set environment variables in `.env`:
```ini
ADFS_CLIENT_ID=your-client-id
ADFS_CLIENT_SECRET=your-client-secret
ADFS_METADATA_URL=https://fs.your-company.com/adfs/.well-known/openid-configuration
```
2. Configure Group Mappings via the Admin UI or API.

View File

@@ -0,0 +1,76 @@
# Research: Multi-User Authentication and Authorization
## 1. Authentication Strategy
### Decision: Hybrid Local + ADFS (OIDC)
We will implement a dual authentication strategy:
1. **Local Auth**: Username/Password stored in `auth.db` with bcrypt hashing.
2. **ADFS**: OpenID Connect (OIDC) integration for enterprise SSO.
**Rationale**:
- **Local Auth**: Ensures the system is usable without external dependencies (ADFS) and provides a fallback for admins.
- **ADFS**: Requirement for corporate environment integration. OIDC is the modern standard supported by ADFS 2016+.
- **Just-In-Time (JIT)**: ADFS users will be provisioned locally upon first successful login if they belong to a mapped AD group.
**Alternatives Considered**:
- *SAML 2.0*: Older protocol, more complex to implement (XML-based) than OIDC. Rejected in favor of OIDC/OAuth2 support in `Authlib`.
- *LDAP Direct Bind*: Requires handling credentials directly, less secure than token-based SSO.
## 2. Session Management
### Decision: Stateless JWT (JSON Web Tokens)
Sessions will be managed using signed JWTs containing `sub` (user_id), `exp` (expiration), and `scopes` (roles).
**Rationale**:
- **Stateless**: No need to query the DB for every request to validate session validity (signature check is fast).
- **Scalable**: Works well with load balancers (though not a primary concern for this scale).
- **Frontend Friendly**: Easy to parse in JS to get user info without an extra API call.
**Security Measures**:
- Short-lived Access Tokens (e.g., 15-30 min).
- HttpOnly Cookies for storage to prevent XSS theft.
- Refresh Token rotation (stored in DB) for long-lived sessions.
## 3. Authorization Model
### Decision: RBAC (Role-Based Access Control)
Permissions are assigned to Roles. Users are assigned one or more Roles.
**Structure**:
- **Permissions**: Granular capabilities (e.g., `plugin:backup:execute`, `plugin:migration:read`).
- **Roles**: Collections of permissions (e.g., `Admin`, `Operator`, `Viewer`).
- **Users**: Assigned to Roles.
**Rationale**:
- Standard industry practice.
- Simplifies management: Admin assigns a role to a user rather than 50 individual permissions.
- AD Group Mapping fits naturally: `AD_Group_X` -> `Role_Y`.
## 4. Persistence
### Decision: Dedicated SQLite Database (`auth.db`)
A separate SQLite database file for authentication data.
**Rationale**:
- **Separation of Concerns**: Keeps auth data distinct from task history or other app data.
- **Relational Integrity**: Enforces foreign keys between Users, Roles, and Permissions better than JSON.
- **Concurrency**: SQLite WAL mode handles concurrent reads/writes better than a single JSON config file.
**Schema Draft**:
- `users` (id, username, password_hash, is_active, auth_source)
- `roles` (id, name, description)
- `permissions` (id, resource, action)
- `role_permissions` (role_id, permission_id)
- `user_roles` (user_id, role_id)
- `ad_group_mappings` (ad_group_name, role_id)
## 5. Frontend Integration
### Decision: SvelteKit Stores + HttpOnly Cookies
Authentication state will be synchronized between the server (cookies) and client (Svelte store).
**Mechanism**:
- Login endpoint sets `access_token` cookie (HttpOnly).
- Client makes API calls; browser automatically sends cookie.
- `hooks.server.ts` (or similar middleware) validates token on server-side rendering.
- Client-side store (`$auth`) holds user profile (decoded from token or fetched via `/me` endpoint) for UI logic (show/hide buttons).

View File

@@ -0,0 +1,138 @@
# Feature Specification: Multi-User Authentication and Authorization
**Feature Branch**: `016-multi-user-auth`
**Created**: 2026-01-26
**Status**: Draft
**Input**: User description: "Нужна поддержка многопользовательского логина. Нужно, чтобы пользователи могли логинится по связке логин/пароль, поддержка adfs, разделение прав доступа по плагинам"
## Clarifications
### Session 2026-01-26
- Q: Permission Model Structure? → A: RBAC (Role-Based Access Control) - Permissions assigned to Roles, Users assigned to Roles.
- Q: Initial Admin Provisioning? → A: CLI Command/Script - Explicit script to create the first admin user.
- Q: ADFS User Role Assignment? → A: AD Group Mapping - Login requires valid AD group membership; AD groups map to local Roles (e.g., 'superset_admin' -> 'Admin').
- Q: Token Management? → A: JWT (JSON Web Tokens) - Stateless, scalable, standard for SPAs.
- Q: Persistence Layer? → A: Dedicated SQLite DB (`auth.db`) - Relational storage for Users, Roles, Permissions.
- Q: Switching Auth Providers? → A: Dual Support - Both Local and ADFS login options are available simultaneously on the login page.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Local User Authentication (Priority: P1)
As a user, I want to log in using a username and password so that I can securely access the application.
**Why this priority**: Basic authentication is the foundation for multi-user support and is required before implementing more complex auth methods or permissions.
**Independent Test**: Can be fully tested by creating a local user account and successfully logging in/out without any external dependencies.
**Acceptance Scenarios**:
1. **Given** a registered user, **When** they enter valid credentials on the login page, **Then** they are redirected to the dashboard and receive a session token.
2. **Given** a registered user, **When** they enter invalid credentials, **Then** they see an error message "Invalid username or password".
3. **Given** an authenticated user, **When** they click logout, **Then** their session is terminated and they are redirected to the login page.
---
### User Story 2 - Plugin-Based Access Control (Priority: P1)
As an administrator, I want to assign specific plugin access rights to users so that I can control who can use sensitive tools (e.g., Backup, Migration).
**Why this priority**: Security is a core requirement. Without granular permissions, all authenticated users would have full administrative access, which defeats the purpose of multi-user support.
**Independent Test**: Create two users with different permissions (e.g., User A has access to "Backup", User B does not). Verify User A can access the Backup tool while User B receives a 403 Forbidden error.
**Acceptance Scenarios**:
1. **Given** a user with "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** the page loads successfully.
2. **Given** a user WITHOUT "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** they are denied access (UI hides the link, API returns 403).
3. **Given** an administrator, **When** they edit a user's permissions, **Then** the changes take effect immediately or upon next login.
---
### User Story 4 - Role Management (Priority: P1)
As an administrator, I want to create and manage roles with specific permissions so that I can easily assign standard access sets to users.
**Why this priority**: Essential for scalable user management. Assigning individual permissions to every user is tedious and error-prone.
**Acceptance Scenarios**:
1. **Given** an administrator, **When** they navigate to the Role Management page, **Then** they see a list of all system roles.
2. **Given** an administrator, **When** they create a new role "Auditor" with "READ" permission on "Logs", **Then** the role is saved and available for assignment.
3. **Given** an administrator, **When** they update a role's permissions, **Then** all users with that role effectively gain/lose those permissions.
**Why this priority**: Security is a core requirement. Without granular permissions, all authenticated users would have full administrative access, which defeats the purpose of multi-user support.
**Independent Test**: Create two users with different permissions (e.g., User A has access to "Backup", User B does not). Verify User A can access the Backup tool while User B receives a 403 Forbidden error.
**Acceptance Scenarios**:
1. **Given** a user with "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** the page loads successfully.
2. **Given** a user WITHOUT "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** they are denied access (UI hides the link, API returns 403).
3. **Given** an administrator, **When** they edit a user's permissions, **Then** the changes take effect immediately or upon next login.
---
### User Story 3 - ADFS Integration (Priority: P2)
As a corporate user, I want to log in using my organization's ADFS credentials so that I don't have to manage a separate password.
**Why this priority**: Essential for enterprise environments but dependent on the core authentication infrastructure being in place (Story 1).
**Independent Test**: Configure the application with a test ADFS provider (or mock). Verify a user can initiate the SSO flow and be logged in automatically.
**Acceptance Scenarios**:
1. **Given** a configured ADFS provider, **When** a user clicks "Login with ADFS", **Then** they are redirected to the identity provider.
2. **Given** a successful ADFS authentication, **When** the user returns to the app, **Then** a local user session is created/matched and they are logged in.
3. **Given** a new ADFS user, **When** they log in for the first time, **Then** a local user record is automatically created (JIT provisioning) with default permissions.
---
### Edge Cases
- What happens when an ADFS user's account is disabled in the local system? (Should block login even if ADFS succeeds)
- How does the system handle concurrent sessions? (Allow or restrict?)
- What happens if a plugin is removed but users still have permission for it? (Graceful handling/cleanup)
- What happens if the ADFS server is unreachable? (Fallback to local login if applicable, or clear error message)
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST support local user authentication via username and password.
- **FR-002**: System MUST support authentication via ADFS (Active Directory Federation Services) using standard federation protocols.
- **FR-003**: System MUST provide a web-based interface to manage users (Create, Read, Update, Delete) - restricted to administrators.
- **FR-004**: System MUST implement Role-Based Access Control (RBAC) where permissions are assigned to Roles, and Roles are assigned to Users.
- **FR-005**: System MUST enforce permissions at the server level for all plugin execution requests.
- **FR-006**: System MUST enforce permissions at the user interface level (hide navigation items/buttons for unauthorized plugins).
- **FR-007**: System MUST securely store local user credentials.
- **FR-008**: System MUST support Just-In-Time (JIT) provisioning for ADFS users ONLY if they belong to a mapped AD group.
- **FR-009**: System MUST provide a CLI utility to create an initial administrator account to prevent lockout during first deployment.
- **FR-010**: System MUST provide a web-based interface for configuring mappings between Active Directory Groups and local System Roles.
- **FR-011**: System MUST use JWT (JSON Web Tokens) for API session management.
- **FR-012**: System MUST persist authentication and authorization data in a dedicated SQLite database (`auth.db`).
- **FR-013**: System MUST provide a unified login interface supporting both Local (Username/Password) and ADFS (SSO Button) authentication methods simultaneously.
- **FR-014**: System MUST provide a web-based interface to manage Roles (Create, Update, Delete) and assign permissions to them.
### Key Entities
- **User**: Represents a system user. Attributes: ID, Username, Email, PasswordHash, AuthSource (Local/ADFS), IsActive, Roles (List[RoleID]).
- **Role**: Named collection of permissions. Attributes: ID, Name, Description, Permissions (List[Permission]).
- **Permission**: Represents access capability. Attributes: ResourceID (e.g., Plugin ID), Action (Execute, Read).
- **ADGroupMapping**: Configuration mapping AD Group names to Role IDs.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Administrators can successfully create a new local user and assign specific plugin permissions in under 2 minutes.
- **SC-002**: Users without permission for a specific plugin are denied access 100% of the time when attempting to use its functions.
- **SC-003**: ADFS login flow completes successfully for valid credentials and maps to the correct local user identity.
- **SC-004**: User interface dynamically updates to show only permitted tools for the logged-in user.
## Assumptions
- The application currently has a simple or placeholder authentication mechanism.
- "Plugin access" refers to the ability to use the plugin's functionality and view its interface.
- A default administrator account will be available upon initial system setup to prevent lockout.

View File

@@ -0,0 +1,98 @@
# Tasks: Multi-User Authentication and Authorization
**Feature Branch**: `016-multi-user-auth`
**Feature Spec**: [`specs/016-multi-user-auth/spec.md`](spec.md)
**Implementation Plan**: [`specs/016-multi-user-auth/plan.md`](plan.md)
## Phase 1: Setup & Infrastructure (Blocking)
*Goal: Initialize the auth database, core dependencies, and backend infrastructure.*
- [x] T001 Install backend dependencies (Authlib, Passlib, PyJWT, SQLAlchemy) in `backend/requirements.txt`
- [x] T002 Implement core configuration for Auth and Database in `backend/src/core/auth/config.py`
- [x] T003 Implement database connection logic for `auth.db` in `backend/src/core/database.py`
- [x] T004 Create SQLAlchemy models for User, Role, Permission in `backend/src/models/auth.py`
- [x] T005 Create migration/init script to generate `auth.db` schema in `backend/src/scripts/init_auth_db.py`
- [x] T006 Implement password hashing utility using Passlib in `backend/src/core/auth/security.py`
- [x] T007 Implement JWT token generation and validation logic in `backend/src/core/auth/jwt.py`
- [x] T008 [P] Implement CLI tool for creating the initial admin user in `backend/src/scripts/create_admin.py`
## Phase 2: User Story 1 - Local User Authentication (Priority: P1)
*Goal: Enable users to log in with username/password and receive a JWT session.*
- [x] T009 [US1] Create Pydantic schemas for User, UserCreate, Token in `backend/src/schemas/auth.py`
- [x] T010 [US1] Implement `AuthRepository` for DB operations in `backend/src/core/auth/repository.py`
- [x] T011 [US1] Implement `AuthService` for login logic (verify password, create token) in `backend/src/services/auth_service.py`
- [x] T012 [US1] Create API endpoint `POST /api/auth/login` in `backend/src/api/auth.py`
- [x] T013 [US1] Implement `get_current_user` dependency for JWT verification in `backend/src/dependencies.py`
- [x] T014 [US1] Create API endpoint `GET /api/auth/me` to retrieve current user profile in `backend/src/api/auth.py`
- [x] T043 [US1] Implement session revocation (Logout) endpoint in `backend/src/api/auth.py`
- [x] T044 [US1] Implement account status check (`is_active`) in authentication flow in `backend/src/services/auth_service.py`
- [x] T015 [US1] Implement frontend auth store (Svelte store) in `frontend/src/lib/auth/store.ts`
- [x] T016 [US1] Implement Login Page UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/login/+page.svelte`
- [x] T017 [US1] Integrate Login Page with Backend API in `frontend/src/routes/login/+page.svelte`
- [x] T018 [US1] Implement `ProtectedRoute` component to redirect unauthenticated users in `frontend/src/components/auth/ProtectedRoute.svelte`
- [x] T037 [US1] Implement password complexity validation logic in `backend/src/core/auth/security.py`
## Phase 3: User Story 2 - Plugin-Based Access Control (Priority: P1)
*Goal: Restrict access to plugins based on user roles and permissions.*
- [x] T019 [US2] Update `PluginBase` to include required permission strings in `backend/src/core/plugin_base.py`
- [x] T020 [US2] Implement `has_permission` dependency for route protection in `backend/src/dependencies.py`
- [x] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py`
- [x] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py`
- [x] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` in `backend/src/api/routes/admin.py`
- [x] T053 [US2] Extend Admin API with User Update/Delete and Role CRUD endpoints in `backend/src/api/routes/admin.py`
- [x] T054 [US2] Add Pydantic schemas for UserUpdate, RoleCreate, RoleUpdate in `backend/src/schemas/auth.py`
- [x] T051 [US2] Implement `adminService.js` for frontend API orchestration
- [x] T055 [US2] Update `adminService.js` with new CRUD methods
- [x] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte`
- [x] T056 [US2] Update Admin User Dashboard to support Edit/Delete operations in `frontend/src/routes/admin/users/+page.svelte`
- [x] T057 [US4] Create Role Management UI in `frontend/src/routes/admin/roles/+page.svelte`
- [x] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte`
- [x] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte`
- [x] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py`
## Phase 4: User Story 3 - ADFS Integration (Priority: P2)
*Goal: Enable corporate SSO login via ADFS and JIT provisioning.*
- [x] T026 [US3] Configure Authlib for ADFS OIDC in `backend/src/core/auth/oauth.py`
- [x] T027 [US3] Create `ADGroupMapping` model in `backend/src/models/auth.py` and update DB init script
- [x] T028 [US3] Implement JIT provisioning logic (create user if maps to group) in `backend/src/services/auth_service.py`
- [x] T029 [US3] Create API endpoints `GET /api/auth/login/adfs` and `GET /api/auth/callback/adfs` in `backend/src/api/auth.py`
- [x] T030 [US3] Update Login Page to include "Login with ADFS" button using `src/lib/ui` in `frontend/src/routes/login/+page.svelte`
- [x] T031 [US3] Implement Admin UI for configuring AD Group Mappings in `frontend/src/routes/admin/settings/+page.svelte`
- [x] T052 [US3] Extend Admin API with AD mapping endpoints in `backend/src/api/routes/admin.py`
- [x] T041 [US3] Create ADFS mock provider for local testing and CI in `backend/tests/auth/mock_adfs.py`
- [x] T046 [US3] Implement token refresh logic for ADFS OIDC tokens in `backend/src/core/auth/jwt.py`
## Phase 5: Polish & Security Hardening
*Goal: Ensure security best practices and smooth UX.*
- [x] T032 Ensure all cookies are set with `HttpOnly` and `Secure` flags in `backend/src/api/auth.py`
- [x] T033 Implement rate limiting and account lockout policy in `backend/src/api/auth.py`
- [x] T034 Verify error messages are generic (no username enumeration) across all auth endpoints
- [x] T035 Add "Session Expired" handling in frontend interceptor in `frontend/src/lib/api/client.ts`
- [x] T036 Final manual test of switching between Local and ADFS login flows
- [x] T040 Add confirmation dialogs for destructive admin actions using `src/lib/ui` in `frontend/src/routes/admin/users/+page.svelte`
- [x] T047 Implement audit logging for security events (login, logout, permission changes) in `backend/src/core/auth/logger.py`
- [x] T048 Perform UI accessibility audit (keyboard nav, ARIA alerts) for all auth components
- [x] T049 Implement unit and integration tests for Local Auth and RBAC in `backend/tests/auth/`
- [x] T050 Implement E2E tests for ADFS flow using mock provider in `tests/e2e/auth.spec.ts`
## Dependencies
1. **Phase 1** must be completed before any User Stories.
2. **Phase 2 (Local Auth)** is the foundation for authentication and session management.
3. **Phase 3 (RBAC)** depends on Phase 2 (needs authenticated users to check permissions).
4. **Phase 4 (ADFS)** depends on Phase 2 (uses same session mechanism) and Phase 3 (needs roles for JIT).
## Implementation Strategy
- **MVP**: Complete Phases 1 and 2. This gives a working auth system with local users.
- **Increment 1**: Complete Phase 3. This adds the critical security controls (RBAC).
- **Increment 2**: Complete Phase 4. This adds corporate SSO convenience.

File diff suppressed because it is too large Load Diff