Compare commits
6 Commits
1042b35d1b
...
016-multi-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e0e26e2f7 | |||
| 18b42f8dd0 | |||
| e7b31accd6 | |||
| d3c3a80ed2 | |||
| cc244c2d86 | |||
| d10c23e658 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
4
.kilocode/workflows/read_semantic.md
Normal file
4
.kilocode/workflows/read_semantic.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: USE SEMANTIC
|
||||
---
|
||||
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
@@ -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
BIN
backend/backend/auth.db
Normal file
Binary file not shown.
137345
backend/logs/app.log.1
137345
backend/logs/app.log.1
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
310
backend/src/api/routes/admin.py
Normal file
310
backend/src/api/routes/admin.py
Normal 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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
"""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
45
backend/src/core/auth/config.py
Normal file
45
backend/src/core/auth/config.py
Normal 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]
|
||||
54
backend/src/core/auth/jwt.py
Normal file
54
backend/src/core/auth/jwt.py
Normal 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]
|
||||
31
backend/src/core/auth/logger.py
Normal file
31
backend/src/core/auth/logger.py
Normal 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]
|
||||
51
backend/src/core/auth/oauth.py
Normal file
51
backend/src/core/auth/oauth.py
Normal 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]
|
||||
123
backend/src/core/auth/repository.py
Normal file
123
backend/src/core/auth/repository.py
Normal 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]
|
||||
42
backend/src/core/auth/security.py
Normal file
42
backend/src/core/auth/security.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
105
backend/src/models/auth.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
128
backend/src/schemas/auth.py
Normal 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]
|
||||
82
backend/src/scripts/create_admin.py
Normal file
82
backend/src/scripts/create_admin.py
Normal 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]
|
||||
44
backend/src/scripts/init_auth_db.py
Normal file
44
backend/src/scripts/init_auth_db.py
Normal 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]
|
||||
116
backend/src/scripts/seed_permissions.py
Normal file
116
backend/src/scripts/seed_permissions.py
Normal 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]
|
||||
115
backend/src/services/auth_service.py
Normal file
115
backend/src/services/auth_service.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
162
backend/tests/test_auth.py
Normal file
162
backend/tests/test_auth.py
Normal 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
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
61
frontend/src/components/auth/ProtectedRoute.svelte
Normal file
61
frontend/src/components/auth/ProtectedRoute.svelte
Normal 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] -->
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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>
|
||||
|
||||
236
frontend/src/routes/admin/roles/+page.svelte
Normal file
236
frontend/src/routes/admin/roles/+page.svelte
Normal 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] -->
|
||||
213
frontend/src/routes/admin/settings/+page.svelte
Normal file
213
frontend/src/routes/admin/settings/+page.svelte
Normal 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] -->
|
||||
284
frontend/src/routes/admin/users/+page.svelte
Normal file
284
frontend/src/routes/admin/users/+page.svelte
Normal 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] -->
|
||||
@@ -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 = [];
|
||||
|
||||
166
frontend/src/routes/login/+page.svelte
Normal file
166
frontend/src/routes/login/+page.svelte
Normal 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] -->
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
241
frontend/src/services/adminService.js
Normal file
241
frontend/src/services/adminService.js
Normal 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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
@@ -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
@@ -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
34
specs/016-multi-user-auth/checklists/requirements.md
Normal file
34
specs/016-multi-user-auth/checklists/requirements.md
Normal 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`
|
||||
39
specs/016-multi-user-auth/checklists/security.md
Normal file
39
specs/016-multi-user-auth/checklists/security.md
Normal 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)
|
||||
31
specs/016-multi-user-auth/checklists/technical.md
Normal file
31
specs/016-multi-user-auth/checklists/technical.md
Normal 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)
|
||||
26
specs/016-multi-user-auth/checklists/testing.md
Normal file
26
specs/016-multi-user-auth/checklists/testing.md
Normal 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)
|
||||
31
specs/016-multi-user-auth/checklists/ux.md
Normal file
31
specs/016-multi-user-auth/checklists/ux.md
Normal 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)
|
||||
132
specs/016-multi-user-auth/contracts/api.yaml
Normal file
132
specs/016-multi-user-auth/contracts/api.yaml
Normal 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
|
||||
86
specs/016-multi-user-auth/data-model.md
Normal file
86
specs/016-multi-user-auth/data-model.md
Normal 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)
|
||||
103
specs/016-multi-user-auth/plan.md
Normal file
103
specs/016-multi-user-auth/plan.md
Normal 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] |
|
||||
54
specs/016-multi-user-auth/quickstart.md
Normal file
54
specs/016-multi-user-auth/quickstart.md
Normal 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.
|
||||
76
specs/016-multi-user-auth/research.md
Normal file
76
specs/016-multi-user-auth/research.md
Normal 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).
|
||||
138
specs/016-multi-user-auth/spec.md
Normal file
138
specs/016-multi-user-auth/spec.md
Normal 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.
|
||||
98
specs/016-multi-user-auth/tasks.md
Normal file
98
specs/016-multi-user-auth/tasks.md
Normal 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
Reference in New Issue
Block a user