diff --git a/ai-hub/app/api/routes/user.py b/ai-hub/app/api/routes/user.py index f1d1548..d8c394c 100644 --- a/ai-hub/app/api/routes/user.py +++ b/ai-hub/app/api/routes/user.py @@ -13,7 +13,7 @@ # Correctly import from your application's schemas and dependencies from app.api.dependencies import ServiceContainer, get_db from app.api import schemas -from app.core.services.user import login_required +from app.core.services.user import login_required, verify_password, hash_password # Setup logging logging.basicConfig(level=logging.INFO) @@ -88,11 +88,52 @@ id=user_id, email=email, is_logged_in=is_logged_in, - is_anonymous=is_anonymous + is_anonymous=is_anonymous, + oidc_configured=settings.OIDC_ENABLED ) except Exception as e: raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + @router.post("/login/local", summary="Local Authentication Fallback") + async def login_local( + request: schemas.LocalLoginRequest, + db: Session = Depends(get_db) + ): + """Day 1: Local Username/Password Login.""" + user = db.query(models.User).filter(models.User.email == request.email).first() + if not user or not user.password_hash: + raise HTTPException(status_code=401, detail="Invalid email or password") + + if not verify_password(request.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid email or password") + + user.last_login_at = datetime.utcnow() + db.commit() + + # In a real environment we would return a JWT here, but existing frontend + # relies on user_id extraction. Returns payload matching callback spec. + return {"user_id": user.id, "email": user.email, "role": user.role} + + @router.put("/password", summary="Update User Password") + async def update_password( + request: schemas.PasswordUpdateRequest, + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id) + ): + if not user_id: + raise HTTPException(status_code=401, detail="Unauthorized") + + user = services.user_service.get_user_by_id(db=db, user_id=user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.password_hash and not verify_password(request.current_password, user.password_hash): + raise HTTPException(status_code=400, detail="Invalid current password") + + user.password_hash = hash_password(request.new_password) + db.commit() + return {"status": "success", "message": "Password updated successfully"} + @router.get("/me/profile", response_model=schemas.UserProfile, summary="Get Current User Profile") async def get_user_profile( db: Session = Depends(get_db), @@ -295,8 +336,8 @@ api_key_override=actual_key, **kwargs ) - # LiteLLM check: litellm models are callable - res = llm("Hello") + # GeneralProvider check + res = await llm.acompletion(prompt="Hello") return schemas.VerifyProviderResponse(success=True, message="Connection successful!") except Exception as e: import logging diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 1820362..99a9980 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -10,12 +10,21 @@ email: str = Field(..., description="The user's email address.") username: str = Field(..., description="The user's username or display name.") +class LocalLoginRequest(BaseModel): + email: str + password: str + +class PasswordUpdateRequest(BaseModel): + current_password: str + new_password: str + class UserStatus(BaseModel): """Schema for the response when checking a user's status.""" id: str = Field(..., description="The internal user ID.") email: str = Field(..., description="The user's email address.") is_logged_in: bool = Field(True, description="Indicates if the user is currently authenticated.") is_anonymous: bool = Field(False, description="Indicates if the user is an anonymous user.") + oidc_configured: bool = Field(False, description="Whether OIDC SSO is enabled on the server.") class UserProfile(BaseModel): id: str diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 28188a9..0e6f5fd 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -67,15 +67,18 @@ except Exception as e: logger.error(f"[M6] Failed to start gRPC server: {e}") - # --- Bootstrap System Skills --- + # --- Bootstrap System Skills and Local Admin --- try: from app.core.skills.bootstrap import bootstrap_system_skills # Use the context manager to ensure session is closed from app.db.session import get_db_session with get_db_session() as db: + # 1. Day 1 Local Admin Seeding + app.state.services.user_service.bootstrap_local_admin(db) + # 2. Base System Skills setup bootstrap_system_skills(db) except Exception as e: - logger.error(f"Failed to bootstrap system skills: {e}") + logger.error(f"Failed to bootstrap database elements: {e}") yield print("Application shutdown...") diff --git a/ai-hub/app/config.py b/ai-hub/app/config.py index f1caef3..da2ce5d 100644 --- a/ai-hub/app/config.py +++ b/ai-hub/app/config.py @@ -17,6 +17,7 @@ super_admins: list[str] = Field(default_factory=list) class OIDCSettings(BaseModel): + enabled: bool = False client_id: str = "" client_secret: str = "" server_url: str = "" @@ -98,6 +99,9 @@ config_from_pydantic.application.super_admins # --- OIDC Settings --- + self.OIDC_ENABLED: bool = os.getenv("OIDC_ENABLED", "false").lower() == "true" or \ + get_from_yaml(["oidc", "enabled"]) or \ + config_from_pydantic.oidc.enabled self.OIDC_CLIENT_ID: str = os.getenv("OIDC_CLIENT_ID") or \ get_from_yaml(["oidc", "client_id"]) or \ config_from_pydantic.oidc.client_id @@ -237,6 +241,7 @@ "embedding_dimension": self.EMBEDDING_DIMENSION }, "oidc": { + "enabled": self.OIDC_ENABLED, "client_id": self.OIDC_CLIENT_ID, "client_secret": self.OIDC_CLIENT_SECRET, "server_url": self.OIDC_SERVER_URL, diff --git a/ai-hub/app/core/services/user.py b/ai-hub/app/core/services/user.py index fae20e7..dd2e49b 100644 --- a/ai-hub/app/core/services/user.py +++ b/ai-hub/app/core/services/user.py @@ -3,14 +3,80 @@ from datetime import datetime from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError +import hashlib +import os +import secrets # Assuming the models are in a file named `models.py` in the `app.db` directory from app.db import models +def hash_password(password: str, salt: str = None) -> str: + """Hashes a password using PBKDF2 with SHA256.""" + if not salt: + salt = secrets.token_hex(16) + encoded_pw = password.encode('utf-8') + encoded_salt = salt.encode('utf-8') + iterations = 100000 + hash_bytes = hashlib.pbkdf2_hmac('sha256', encoded_pw, encoded_salt, iterations) + return f"{salt}${iterations}${hash_bytes.hex()}" + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verifies a plain password against a stored hash.""" + if not hashed_password or '$' not in hashed_password: + return False + parts = hashed_password.split('$') + if len(parts) != 3: + return False + salt, iterations, stored_hash = parts + + encoded_pw = plain_password.encode('utf-8') + encoded_salt = salt.encode('utf-8') + hash_bytes = hashlib.pbkdf2_hmac('sha256', encoded_pw, encoded_salt, int(iterations)) + return hash_bytes.hex() == stored_hash + class UserService: def __init__(self): pass + def bootstrap_local_admin(self, db: Session): + """ + [Day 1] Checks environment for CORTEX_ADMIN_PASSWORD and SUPER_ADMINS. + If present, creates or updates the primary super admin with local login credentials. + """ + from app.config import settings + admin_email = settings.SUPER_ADMINS[0] if settings.SUPER_ADMINS else None + admin_password = os.getenv("CORTEX_ADMIN_PASSWORD") + + if not admin_email or not admin_password: + return + + try: + admin = db.query(models.User).filter(models.User.email == admin_email).first() + if admin: + # Update password only if the user has NO configured password + # This prevents overwriting a user-changed password on every reboot + if not admin.password_hash: + admin.password_hash = hash_password(admin_password) + db.commit() + else: + default_group = self.get_or_create_default_group(db) + new_admin = models.User( + id=str(uuid.uuid4()), + email=admin_email, + username=admin_email.split("@")[0], + role="admin", + group_id=default_group.id, + password_hash=hash_password(admin_password), + created_at=datetime.utcnow(), + last_login_at=datetime.utcnow() + ) + db.add(new_admin) + db.commit() + print(f"[Day 1] Bootstrapped local admin account for {admin_email}") + except Exception as e: + db.rollback() + print(f"Failed to bootstrap local admin: {e}") + def save_user(self, db: Session, oidc_id: str, email: str, username: str) -> str: """ Saves or updates a user record based on their OIDC ID. diff --git a/ai-hub/app/db/migrate.py b/ai-hub/app/db/migrate.py index 46d0c3f..4abf9f6 100644 --- a/ai-hub/app/db/migrate.py +++ b/ai-hub/app/db/migrate.py @@ -65,6 +65,17 @@ else: logger.info(f"Column '{col_name}' already exists in 'sessions'.") + # Users table migrations for local authentication + user_columns = [c["name"] for c in inspector.get_columns("users")] + if "password_hash" not in user_columns: + logger.info("Adding column 'password_hash' to 'users' table...") + try: + conn.execute(text("ALTER TABLE users ADD COLUMN password_hash TEXT")) + conn.commit() + logger.info("Successfully added 'password_hash'.") + except Exception as e: + logger.error(f"Failed to add column 'password_hash': {e}") + # --- M6: Agent Node Tables --- # Create agent_nodes table if it doesn't exist if not inspector.has_table("agent_nodes"): diff --git a/ai-hub/app/db/models/user.py b/ai-hub/app/db/models/user.py index a899e68..780bce3 100644 --- a/ai-hub/app/db/models/user.py +++ b/ai-hub/app/db/models/user.py @@ -24,6 +24,7 @@ oidc_id = Column(String, unique=True, nullable=True) email = Column(String, nullable=True) username = Column(String, nullable=True) + password_hash = Column(String, nullable=True) full_name = Column(String, nullable=True) role = Column(String, default="user", nullable=False) group_id = Column(String, ForeignKey('groups.id'), nullable=True) diff --git a/docker-compose.yml b/docker-compose.yml index 9fd897c..0c6df07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,29 @@ cpus: '1.0' memory: 1G + # Default Local Sandbox Node (Day 0 Experience) + sandbox-node: + build: ./agent-node + container_name: cortex_sandbox_node + restart: always + environment: + - AGENT_NODE_ID=sandbox-node + - AGENT_NODE_DESC=Default Sandbox Node + - GRPC_ENDPOINT=ai-hub:50051 + - AGENT_AUTH_TOKEN=sandbox-token-1234 + - AGENT_SECRET_KEY=${SECRET_KEY:-default-insecure-key} + - AGENT_TLS_ENABLED=false + volumes: + - ./agent-node:/app/agent-node-source:ro + - sandbox_sync:/app/sync + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + depends_on: + - ai-hub + # Dedicated Browser Service (M6 Refactor) browser-service: build: ./browser-service @@ -69,6 +92,8 @@ volumes: ai_hub_data: driver: local + sandbox_sync: + driver: local browser_shm: driver: local driver_opts: diff --git a/docs/auth_tls_todo.md b/docs/auth_tls_todo.md index 653fb2a..e6bdfb2 100644 --- a/docs/auth_tls_todo.md +++ b/docs/auth_tls_todo.md @@ -6,14 +6,14 @@ These tasks ensure the out-of-the-box local developer experience functions fully. - [x] **Infrastructure**: Fix the `.env` override trap in `docker-compose.yml`. - [x] **Setup Script**: Update `setup.sh` to accept optional `config.yaml` to cure the "Brain Dead" state. -- [ ] **Infrastructure**: Include a bundled `sandbox-node` container in `docker-compose.yml` so the Hub isn't an "Empty Shell" on startup. +- [x] **Infrastructure**: Include a bundled `sandbox-node` container in `docker-compose.yml` so the Hub isn't an "Empty Shell" on startup. ## Phase 2: Day 1 Local Auth Fallback Enable local authentication using the `CORTEX_ADMIN_PASSWORD` generated by the setup script. -- [ ] **Database Model**: Update `User` model (`app/db/models/user.py`) to include a nullable `password_hash` column. -- [ ] **Configuration**: Update `Settings` (`app/config.py`) to make OIDC settings optional and add an `oidc_enabled: bool` flag. -- [ ] **Backend Initialization**: If `CORTEX_ADMIN_PASSWORD` is present in the environment for the `SUPER_ADMINS` initialization, hash it and assign it to the admin account. -- [ ] **API Routes**: Create local login endpoints (`POST /api/v1/users/login/local` to issue JWTs) and (`PUT /api/v1/users/password` for password resets). +- [x] **Database Model**: Update `User` model (`app/db/models/user.py`) to include a nullable `password_hash` column. +- [x] **Configuration**: Update `Settings` (`app/config.py`) to make OIDC settings optional and add an `oidc_enabled: bool` flag. +- [x] **Backend Initialization**: If `CORTEX_ADMIN_PASSWORD` is present in the environment for the `SUPER_ADMINS` initialization, hash it and assign it to the admin account. +- [x] **API Routes**: Create local login endpoints (`POST /api/v1/users/login/local` to issue JWTs) and (`PUT /api/v1/users/password` for password resets). - [ ] **Frontend**: Redesign the Auth/Login page to display a Username/Password default form. ## Phase 3: Day 1 Swarm Control (Insecure/Local Status)