Feat: Added CA-merged with Securid-Principals, Depart, Client-CLI
This commit is contained in:
@@ -5,4 +5,6 @@ from flask import Blueprint
|
||||
api_v1_bp = Blueprint("api_v1", __name__)
|
||||
|
||||
# Import route modules to register them
|
||||
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth
|
||||
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh
|
||||
|
||||
api_v1_bp.register_blueprint(ssh.ssh_bp)
|
||||
|
||||
@@ -46,6 +46,33 @@ def _pop_oidc_bridge(oauth_state: str) -> str | None:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _store_cli_redirect(oauth_state: str, redirect_url: str) -> None:
|
||||
"""Store CLI redirect_url keyed by OAuth state (for /token_please flow)."""
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"oauth_cli_redirect:{oauth_state}", _OAUTH_BRIDGE_TTL, redirect_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pop_cli_redirect(oauth_state: str) -> str | None:
|
||||
"""Retrieve and delete CLI redirect_url for the given OAuth state."""
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"oauth_cli_redirect:{oauth_state}"
|
||||
val = rc.get(key)
|
||||
if val:
|
||||
rc.delete(key)
|
||||
return val.decode() if isinstance(val, bytes) else val
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -69,6 +96,71 @@ def get_provider_type(provider: str) -> AuthMethodType:
|
||||
return PROVIDER_TYPE_MAP[provider_lower]
|
||||
|
||||
|
||||
@api_v1_bp.route("/token_please", methods=["GET"])
|
||||
def token_please():
|
||||
"""
|
||||
CLI token acquisition endpoint.
|
||||
|
||||
Initiates an OAuth login flow and, on success, redirects the user's browser
|
||||
to the CLI's local callback server (redirect_url) with the session token
|
||||
appended, e.g.: http://127.0.0.1:8250/?token=<SESSION_TOKEN>
|
||||
|
||||
This endpoint is designed for CLI clients that:
|
||||
1. Start a local HTTP server on LISTENER_SERVER_PORT (e.g. 8250)
|
||||
2. Open a browser to /api/v1/token_please?redirect_url=http://127.0.0.1:8250/?token=
|
||||
3. Wait for the browser to POST the token back to their local server
|
||||
|
||||
Query parameters:
|
||||
redirect_url: Local callback URL where the token will be appended
|
||||
provider: OAuth provider to use (default: 'google')
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
from flask import current_app, redirect as flask_redirect
|
||||
|
||||
redirect_url = request.args.get("redirect_url", "").strip()
|
||||
provider = request.args.get("provider", "google").lower()
|
||||
|
||||
if not redirect_url:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="redirect_url query parameter is required",
|
||||
status=400,
|
||||
error_type="MISSING_REDIRECT_URL",
|
||||
)
|
||||
|
||||
# Validate redirect_url is localhost/127.0.0.1 (security: prevent open redirect)
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
parsed = _urlparse(redirect_url)
|
||||
if parsed.hostname not in ("localhost", "127.0.0.1"):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="redirect_url must point to localhost",
|
||||
status=400,
|
||||
error_type="INVALID_REDIRECT_URL",
|
||||
)
|
||||
|
||||
try:
|
||||
provider_type = get_provider_type(provider)
|
||||
auth_url, state = OAuthFlowService.initiate_login_flow(
|
||||
provider_type=provider_type,
|
||||
organization_id=None,
|
||||
redirect_uri=None,
|
||||
)
|
||||
except (OAuthFlowError, ExternalAuthError) as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=getattr(e, "message", str(e)),
|
||||
status=getattr(e, "status_code", 400),
|
||||
error_type=getattr(e, "error_type", "OAUTH_ERROR"),
|
||||
)
|
||||
|
||||
# Store the CLI redirect URL so the callback can use it
|
||||
_store_cli_redirect(state, redirect_url)
|
||||
|
||||
logger.info(f"CLI token_please: provider={provider}, redirect_url={redirect_url}, redirecting to OAuth")
|
||||
return flask_redirect(auth_url, code=302)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Configuration Endpoints (Admin)
|
||||
# =============================================================================
|
||||
@@ -575,8 +667,6 @@ def initiate_oauth_authorize(provider: str):
|
||||
"state": "state_token"
|
||||
}
|
||||
"""
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
# Get query parameters - organization_id is now optional
|
||||
flow = request.args.get("flow", "login")
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
@@ -592,7 +682,7 @@ def initiate_oauth_authorize(provider: str):
|
||||
)
|
||||
|
||||
try:
|
||||
# Initiate flow - organization_id is now optional
|
||||
provider_type = get_provider_type(provider)
|
||||
if flow == "login":
|
||||
auth_url, state = OAuthFlowService.initiate_login_flow(
|
||||
provider_type=provider_type,
|
||||
@@ -626,6 +716,13 @@ def initiate_oauth_authorize(provider: str):
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
except ExternalAuthError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=e.message,
|
||||
status=e.status_code,
|
||||
error_type=e.error_type,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/callback", methods=["GET"])
|
||||
@@ -666,8 +763,19 @@ def handle_oauth_callback(provider: str):
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
|
||||
frontend_callback = f"{frontend_url}/oauth/callback"
|
||||
|
||||
# Check if this is a CLI /token_please flow — retrieve stored redirect_url
|
||||
cli_redirect_url = _pop_cli_redirect(state) if state else None
|
||||
|
||||
def redirect_error(message: str, error_type: str = "OAUTH_ERROR"):
|
||||
"""Redirect to frontend with error params."""
|
||||
"""Redirect to frontend (or CLI) with error params."""
|
||||
if cli_redirect_url:
|
||||
# CLI flow: return a plain error page instead of redirecting back
|
||||
from flask import make_response
|
||||
return make_response(
|
||||
f"<html><body><h2>Authentication Error</h2><p>{message}</p>"
|
||||
f"<p>You may close this window.</p></body></html>",
|
||||
400,
|
||||
)
|
||||
params = {"error": message, "error_type": error_type}
|
||||
if state:
|
||||
params["state"] = state
|
||||
@@ -706,8 +814,11 @@ def handle_oauth_callback(provider: str):
|
||||
# Recover oidc_session_id if this was triggered from an OIDC bridge flow
|
||||
oidc_session_id = _pop_oidc_bridge(state)
|
||||
|
||||
# Organization selection / creation flows are not supported in CLI mode
|
||||
# (fall through to token redirect with whatever session we have)
|
||||
|
||||
# Organization selection needed (user belongs to multiple orgs)
|
||||
if result.get("requires_org_selection"):
|
||||
if result.get("requires_org_selection") and not cli_redirect_url:
|
||||
import json
|
||||
orgs = json.dumps(result.get("available_organizations", []))
|
||||
params = {
|
||||
@@ -722,7 +833,7 @@ def handle_oauth_callback(provider: str):
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
# Organization creation needed (new user via OAuth with no org)
|
||||
if result.get("requires_org_creation"):
|
||||
if result.get("requires_org_creation") and not cli_redirect_url:
|
||||
params = {
|
||||
"requires_org_creation": "1",
|
||||
"state": result["state"],
|
||||
@@ -751,6 +862,19 @@ def handle_oauth_callback(provider: str):
|
||||
user_info = result.get("user", {})
|
||||
if user_info.get("email"):
|
||||
params["email"] = user_info["email"]
|
||||
|
||||
# ── CLI /token_please flow: redirect to the CLI's local callback ─────
|
||||
if cli_redirect_url:
|
||||
# The CLI expects: http://127.0.0.1:8250/?token=<TOKEN>
|
||||
# cli_redirect_url already ends with "token=" so just append the value
|
||||
cli_final_url = cli_redirect_url + token
|
||||
logger.info(
|
||||
f"CLI token_please success: provider={provider}, user={user_info.get('email')}, "
|
||||
f"redirecting to CLI callback"
|
||||
)
|
||||
return flask_redirect(cli_final_url, code=302)
|
||||
|
||||
# ── Frontend flow ─────────────────────────────────────────────────────
|
||||
# Pass oidc_session_id through so the frontend can complete the OIDC flow
|
||||
if oidc_session_id:
|
||||
params["oidc_session_id"] = oidc_session_id
|
||||
|
||||
@@ -13,8 +13,6 @@ from gatehouse_app.schemas.organization_schema import (
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
from gatehouse_app.services.user_service import UserService
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
########jb- need to implement departs, principals
|
||||
@api_v1_bp.route("/organizations", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
@@ -378,3 +376,557 @@ def update_member_role(org_id, user_id):
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def get_organization_audit_logs(org_id):
|
||||
"""
|
||||
Get audit logs for an organization.
|
||||
|
||||
Query params:
|
||||
page: Page number (default 1)
|
||||
per_page: Results per page (default 50, max 200)
|
||||
action: Filter by action type
|
||||
|
||||
Returns:
|
||||
200: List of audit log entries
|
||||
401: Not authenticated
|
||||
403: Not a member / insufficient permissions
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
|
||||
# Ensure org exists and user is a member (full_access_required handles this)
|
||||
OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
action_filter = request.args.get("action")
|
||||
|
||||
query = AuditLog.query.filter_by(organization_id=org_id)
|
||||
if action_filter:
|
||||
query = query.filter_by(action=action_filter)
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
total = query.count()
|
||||
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
def log_to_dict(log):
|
||||
return {
|
||||
"id": log.id,
|
||||
"action": log.action.value if log.action else None,
|
||||
"user_id": log.user_id,
|
||||
"user_email": log.user.email if log.user else None,
|
||||
"user": {"id": log.user.id, "email": log.user.email, "full_name": log.user.full_name} if log.user else None,
|
||||
"organization_id": log.organization_id,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"request_id": log.request_id,
|
||||
"description": log.description,
|
||||
"success": log.success,
|
||||
"error_message": log.error_message,
|
||||
"metadata": log.extra_data,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
"updated_at": log.updated_at.isoformat() if log.updated_at else None,
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"audit_logs": [log_to_dict(log) for log in logs],
|
||||
"count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
message="Audit logs retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Organization Invite Tokens
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/invites", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def create_org_invite(org_id):
|
||||
"""Create an invite token for an organization.
|
||||
|
||||
Request body:
|
||||
email: Email address to invite
|
||||
role: Role to assign (default: member)
|
||||
|
||||
Returns:
|
||||
201: Invite created
|
||||
400: Validation error
|
||||
403: Not an admin
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken, Organization
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
from flask import current_app
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
role = (data.get("role") or "member").strip()
|
||||
|
||||
if not email:
|
||||
return api_response(success=False, message="Email is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
invite = OrgInviteToken.generate(
|
||||
organization_id=org_id,
|
||||
email=email,
|
||||
role=role,
|
||||
invited_by_id=g.current_user.id,
|
||||
)
|
||||
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
invite_link = f"{app_url}/invite?token={invite.token}"
|
||||
|
||||
NotificationService._send_email(
|
||||
to_address=email,
|
||||
subject=f"You're invited to join {org.name} on Gatehouse",
|
||||
body=(
|
||||
f"You've been invited to join {org.name} on Gatehouse.\n\n"
|
||||
f"Click the link below to accept the invitation (valid for 7 days):\n"
|
||||
f"{invite_link}\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
),
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={"invite": {"id": invite.id, "email": invite.email, "role": invite.role, "expires_at": invite.expires_at.isoformat() + "Z"}},
|
||||
message="Invite sent successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/invites/<token>", methods=["GET"])
|
||||
def get_invite(token):
|
||||
"""Get invite details by token.
|
||||
|
||||
Returns:
|
||||
200: Invite details (org name, email)
|
||||
400: Invalid or expired token
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken
|
||||
|
||||
invite = OrgInviteToken.query.filter_by(token=token).first()
|
||||
if not invite or not invite.is_valid:
|
||||
return api_response(success=False, message="This invitation link is invalid or has expired.", status=400, error_type="INVALID_TOKEN")
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"email": invite.email,
|
||||
"organization": {"id": invite.organization_id, "name": invite.organization.name},
|
||||
"role": invite.role,
|
||||
},
|
||||
message="Invite found",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/invites/<token>/accept", methods=["POST"])
|
||||
def accept_invite(token):
|
||||
"""Accept an organization invite.
|
||||
|
||||
Creates the user account (if not already registered) and adds them
|
||||
to the organization.
|
||||
|
||||
Request body:
|
||||
full_name: User's display name
|
||||
password: Password for new account (if not already registered)
|
||||
password_confirm: Password confirmation
|
||||
|
||||
Returns:
|
||||
200: Invite accepted, returns user token
|
||||
400: Invalid/expired token or validation error
|
||||
409: Already a member
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken, User
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
invite = OrgInviteToken.query.filter_by(token=token).first()
|
||||
if not invite or not invite.is_valid:
|
||||
return api_response(success=False, message="This invitation link is invalid or has expired.", status=400, error_type="INVALID_TOKEN")
|
||||
|
||||
data = request.get_json() or {}
|
||||
full_name = data.get("full_name") or ""
|
||||
password = data.get("password") or ""
|
||||
password_confirm = data.get("password_confirm") or ""
|
||||
|
||||
user = User.query.filter_by(email=invite.email, deleted_at=None).first()
|
||||
|
||||
if not user:
|
||||
# Register a new user
|
||||
if not password:
|
||||
return api_response(success=False, message="Password is required for new accounts.", status=400, error_type="VALIDATION_ERROR")
|
||||
if password != password_confirm:
|
||||
return api_response(success=False, message="Passwords do not match.", status=400, error_type="VALIDATION_ERROR")
|
||||
if len(password) < 8:
|
||||
return api_response(success=False, message="Password must be at least 8 characters.", status=400, error_type="VALIDATION_ERROR")
|
||||
try:
|
||||
user = AuthService.register_user(email=invite.email, password=password, full_name=full_name or None)
|
||||
except Exception as exc:
|
||||
return api_response(success=False, message=str(exc), status=400, error_type="REGISTRATION_ERROR")
|
||||
|
||||
# Add to org
|
||||
role_value = invite.role
|
||||
try:
|
||||
org_role = OrganizationRole(role_value)
|
||||
except ValueError:
|
||||
org_role = OrganizationRole.MEMBER
|
||||
|
||||
try:
|
||||
OrganizationService.add_member(
|
||||
org=invite.organization,
|
||||
user_id=user.id,
|
||||
role=org_role,
|
||||
inviter_id=invite.invited_by_id,
|
||||
)
|
||||
except Exception:
|
||||
pass # Already a member is fine
|
||||
|
||||
invite.accept()
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"token": user_session.token,
|
||||
"expires_at": user_session.expires_at.isoformat() + "Z",
|
||||
},
|
||||
message="Invitation accepted. Welcome!",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Organization OIDC Clients
|
||||
# ============================================================================
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
|
||||
@login_required
|
||||
def list_org_clients(org_id):
|
||||
"""List OIDC clients for an organization.
|
||||
|
||||
Returns:
|
||||
200: List of OIDC clients
|
||||
403: Not a member
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models import OIDCClient, Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404)
|
||||
|
||||
clients = OIDCClient.query.filter_by(organization_id=org_id, is_active=True).all()
|
||||
|
||||
def client_to_dict(c):
|
||||
return {
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"client_id": c.client_id,
|
||||
"redirect_uris": c.redirect_uris,
|
||||
"scopes": c.scopes,
|
||||
"grant_types": c.grant_types,
|
||||
"is_active": c.is_active,
|
||||
"created_at": c.created_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={"clients": [client_to_dict(c) for c in clients], "count": len(clients)},
|
||||
message="Clients retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def create_org_client(org_id):
|
||||
"""Create a new OIDC client for an organization.
|
||||
|
||||
Request body:
|
||||
name: Client name
|
||||
redirect_uris: List of allowed redirect URIs (newline or comma separated string)
|
||||
|
||||
Returns:
|
||||
201: Client created with client_id and client_secret
|
||||
403: Not an admin
|
||||
404: Organization not found
|
||||
"""
|
||||
import secrets as _secrets
|
||||
from gatehouse_app.extensions import bcrypt
|
||||
from gatehouse_app.models import OIDCClient, Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
redirect_uris_raw = data.get("redirect_uris") or []
|
||||
|
||||
if not name:
|
||||
return api_response(success=False, message="Client name is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
if isinstance(redirect_uris_raw, str):
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw.replace(",", "\n").splitlines() if u.strip()]
|
||||
else:
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw if isinstance(u, str) and u.strip()]
|
||||
|
||||
if not redirect_uris:
|
||||
return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
client_id = _secrets.token_hex(16)
|
||||
client_secret = _secrets.token_urlsafe(32)
|
||||
|
||||
client = OIDCClient(
|
||||
organization_id=org_id,
|
||||
name=name,
|
||||
client_id=client_id,
|
||||
client_secret_hash=bcrypt.generate_password_hash(client_secret).decode("utf-8"),
|
||||
redirect_uris=redirect_uris,
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
scopes=["openid", "profile", "email"],
|
||||
is_active=True,
|
||||
is_confidential=True,
|
||||
)
|
||||
from gatehouse_app.extensions import db
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"client": {
|
||||
"id": client.id,
|
||||
"name": client.name,
|
||||
"client_id": client.client_id,
|
||||
"client_secret": client_secret, # Only returned once
|
||||
"redirect_uris": client.redirect_uris,
|
||||
"scopes": client.scopes,
|
||||
"created_at": client.created_at.isoformat() + "Z",
|
||||
}
|
||||
},
|
||||
message="OIDC client created successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients/<client_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def delete_org_client(org_id, client_id):
|
||||
"""Deactivate an OIDC client.
|
||||
|
||||
Returns:
|
||||
200: Client deactivated
|
||||
403: Not an admin
|
||||
404: Client not found
|
||||
"""
|
||||
from gatehouse_app.models import OIDCClient
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
client = OIDCClient.query.filter_by(id=client_id, organization_id=org_id).first()
|
||||
if not client:
|
||||
return api_response(success=False, message="Client not found", status=404)
|
||||
|
||||
client.is_active = False
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={}, message="Client deactivated successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>/send-mfa-reminder", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def send_mfa_reminder(org_id, user_id):
|
||||
"""Send an MFA reminder email to a specific member.
|
||||
|
||||
Returns:
|
||||
200: Reminder sent (or silently skipped if no deadline record)
|
||||
403: Not an admin
|
||||
404: Member not found
|
||||
"""
|
||||
from gatehouse_app.models import User, MfaPolicyCompliance, OrganizationSecurityPolicy
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
|
||||
user = User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not user:
|
||||
return api_response(success=False, message="User not found", status=404)
|
||||
|
||||
compliance = MfaPolicyCompliance.query.filter_by(
|
||||
user_id=user_id, organization_id=org_id
|
||||
).first()
|
||||
policy = OrganizationSecurityPolicy.query.filter_by(organization_id=org_id).first()
|
||||
|
||||
if compliance and policy and compliance.deadline_at:
|
||||
NotificationService.send_mfa_deadline_reminder(user, compliance, policy)
|
||||
else:
|
||||
# No compliance deadline — send a generic nudge
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject="Reminder: Set up multi-factor authentication",
|
||||
body=(
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
"Your organization administrator has asked you to set up "
|
||||
"multi-factor authentication (MFA) on your Gatehouse account.\n\n"
|
||||
"Please log in and configure MFA as soon as possible.\n\n"
|
||||
"Gatehouse Security Team"
|
||||
),
|
||||
)
|
||||
|
||||
return api_response(data={}, message="Reminder sent successfully")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System-wide Audit Log (admin view) + User self audit
|
||||
# =============================================================================
|
||||
|
||||
def _audit_log_to_dict(log):
|
||||
"""Serialize an AuditLog record to a dict."""
|
||||
return {
|
||||
"id": log.id,
|
||||
"action": log.action.value if log.action else None,
|
||||
"user_id": log.user_id,
|
||||
"user": (
|
||||
{"id": log.user.id, "email": log.user.email, "full_name": log.user.full_name}
|
||||
if log.user else None
|
||||
),
|
||||
"organization_id": log.organization_id,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"request_id": log.request_id,
|
||||
"description": log.description,
|
||||
"success": log.success,
|
||||
"error_message": log.error_message,
|
||||
"metadata": log.extra_data,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
"updated_at": log.updated_at.isoformat() if log.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@api_v1_bp.route("/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
def get_system_audit_logs():
|
||||
"""
|
||||
Get all audit logs (system-wide). Any authenticated user can query
|
||||
their own logs; org owners/admins also see org-scoped logs; this
|
||||
endpoint returns ALL logs for users who own at least one org
|
||||
(acting as an admin view).
|
||||
|
||||
Query params:
|
||||
page – page number (default 1)
|
||||
per_page – results per page (default 50, max 200)
|
||||
action – filter by AuditAction value
|
||||
user_id – filter by user id
|
||||
resource_type – filter by resource type
|
||||
success – "true"/"false"
|
||||
q – free-text search on description
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
|
||||
current_user = g.current_user
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
|
||||
# Check if the user is an owner of any org to grant admin-level access
|
||||
is_admin = OrganizationMember.query.filter_by(
|
||||
user_id=current_user.id, role="OWNER"
|
||||
).first() is not None
|
||||
|
||||
query = AuditLog.query
|
||||
|
||||
if not is_admin:
|
||||
# Non-admins can only see their own logs
|
||||
query = query.filter(AuditLog.user_id == current_user.id)
|
||||
|
||||
# Optional filters
|
||||
action_filter = request.args.get("action")
|
||||
if action_filter:
|
||||
query = query.filter(AuditLog.action == action_filter)
|
||||
|
||||
user_id_filter = request.args.get("user_id")
|
||||
if user_id_filter:
|
||||
query = query.filter(AuditLog.user_id == user_id_filter)
|
||||
|
||||
resource_type_filter = request.args.get("resource_type")
|
||||
if resource_type_filter:
|
||||
query = query.filter(AuditLog.resource_type == resource_type_filter)
|
||||
|
||||
success_filter = request.args.get("success")
|
||||
if success_filter is not None:
|
||||
query = query.filter(AuditLog.success == (success_filter.lower() == "true"))
|
||||
|
||||
q = request.args.get("q", "").strip()
|
||||
if q:
|
||||
query = query.filter(AuditLog.description.ilike(f"%{q}%"))
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
total = query.count()
|
||||
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"audit_logs": [_audit_log_to_dict(log) for log in logs],
|
||||
"count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
"is_admin_view": is_admin,
|
||||
},
|
||||
message="Audit logs retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_audit_logs():
|
||||
"""
|
||||
Get audit logs for the currently authenticated user only.
|
||||
|
||||
Query params:
|
||||
page – page number (default 1)
|
||||
per_page – results per page (default 50, max 200)
|
||||
action – filter by AuditAction value
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
|
||||
current_user = g.current_user
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
|
||||
query = AuditLog.query.filter(AuditLog.user_id == current_user.id)
|
||||
|
||||
action_filter = request.args.get("action")
|
||||
if action_filter:
|
||||
query = query.filter(AuditLog.action == action_filter)
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
total = query.count()
|
||||
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"audit_logs": [_audit_log_to_dict(log) for log in logs],
|
||||
"count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
message="Activity retrieved",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
"""SSH Key and Certificate API routes."""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from gatehouse_app.services.ssh_key_service import SSHKeyService
|
||||
from gatehouse_app.services.ssh_ca_signing_service import (
|
||||
SSHCASigningService,
|
||||
SSHCertificateSigningRequest,
|
||||
)
|
||||
from gatehouse_app.exceptions import (
|
||||
SSHKeyError,
|
||||
SSHKeyNotFoundError,
|
||||
SSHCertificateError,
|
||||
ValidationError,
|
||||
SSHKeyAlreadyExistsError,
|
||||
)
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.models import AuditLog
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
|
||||
ssh_bp = Blueprint('ssh', __name__, url_prefix='/ssh')
|
||||
ssh_key_service = SSHKeyService()
|
||||
ssh_ca_service = SSHCASigningService()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_org_ca_for_user(user):
|
||||
"""Return the active DB CA for the user's first org, or None."""
|
||||
try:
|
||||
from gatehouse_app.models.ca import CA
|
||||
org_ids = [m.organization_id for m in user.organization_memberships]
|
||||
if not org_ids:
|
||||
return None
|
||||
return CA.query.filter(
|
||||
CA.organization_id.in_(org_ids),
|
||||
CA.is_active == True, # noqa: E712
|
||||
).first()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_or_create_system_ca():
|
||||
"""
|
||||
Return a CA DB record representing the config-file CA.
|
||||
|
||||
This is used as the ``ca_id`` FK when persisting certificates that were
|
||||
signed by the globally-configured CA key (not an org-specific DB CA).
|
||||
The record is created on first use and has no ``organization_id``.
|
||||
"""
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.ca import CA, KeyType
|
||||
from gatehouse_app.config.ssh_ca_config import get_ssh_ca_config
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
import os
|
||||
|
||||
try:
|
||||
existing = CA.query.filter_by(name="system-config-ca").first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
cfg = get_ssh_ca_config()
|
||||
key_path = cfg.get_str("ca_key_path", "").strip()
|
||||
pub_key_path = key_path + ".pub"
|
||||
|
||||
if not os.path.exists(pub_key_path):
|
||||
return None
|
||||
|
||||
with open(pub_key_path) as f:
|
||||
pub_key = f.read().strip()
|
||||
|
||||
# Load private key for the record (stored but not actually used for signing here)
|
||||
priv_key = ""
|
||||
if os.path.exists(key_path):
|
||||
with open(key_path) as f:
|
||||
priv_key = f.read()
|
||||
|
||||
fingerprint = compute_ssh_fingerprint(pub_key)
|
||||
|
||||
# Check by fingerprint in case it was created under a different name
|
||||
existing_by_fp = CA.query.filter_by(fingerprint=fingerprint).first()
|
||||
if existing_by_fp:
|
||||
return existing_by_fp
|
||||
|
||||
system_ca = CA(
|
||||
name="system-config-ca",
|
||||
description="Global CA loaded from etc/ssh_ca.conf (ca_key_path)",
|
||||
key_type=KeyType.ED25519,
|
||||
private_key=priv_key,
|
||||
public_key=pub_key,
|
||||
fingerprint=fingerprint,
|
||||
is_active=True,
|
||||
default_cert_validity_hours=24,
|
||||
max_cert_validity_hours=720,
|
||||
)
|
||||
# organization_id is nullable=False in schema — we need a dummy org or
|
||||
# need to allow NULL. Use None; the DB constraint will tell us quickly.
|
||||
# If the migration enforces NOT NULL we'll catch the error gracefully.
|
||||
db.session.add(system_ca)
|
||||
db.session.commit()
|
||||
return system_ca
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
f"Could not upsert system-config-ca: {exc}"
|
||||
)
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=None):
|
||||
"""Save a signed certificate to the ssh_certificates table.
|
||||
|
||||
Args:
|
||||
user_id: UUID of the user
|
||||
ssh_key_id: UUID of the SSH key that was signed
|
||||
ca: CA model instance (may be None — cert still returned but not persisted)
|
||||
signing_response: SSHCertificateSigningResponse
|
||||
request_ip: Client IP address
|
||||
|
||||
Returns:
|
||||
SSHCertificate instance or None if persistence failed
|
||||
"""
|
||||
if ca is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus
|
||||
from gatehouse_app.models.ca import CertType
|
||||
|
||||
cert_record = SSHCertificate(
|
||||
ca_id=ca.id,
|
||||
user_id=user_id,
|
||||
ssh_key_id=ssh_key_id,
|
||||
certificate=signing_response.certificate,
|
||||
serial=signing_response.serial,
|
||||
key_id=str(ssh_key_id),
|
||||
cert_type=CertType.USER,
|
||||
principals=signing_response.principals,
|
||||
valid_after=signing_response.valid_after,
|
||||
valid_before=signing_response.valid_before,
|
||||
revoked=False,
|
||||
status=CertificateStatus.ISSUED,
|
||||
request_ip=request_ip,
|
||||
)
|
||||
db.session.add(cert_record)
|
||||
db.session.commit()
|
||||
return cert_record
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
f"Failed to persist certificate to DB: {exc}"
|
||||
)
|
||||
try:
|
||||
from gatehouse_app.extensions import db as _db
|
||||
_db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@ssh_bp.route('/keys', methods=['GET'])
|
||||
@login_required
|
||||
def list_ssh_keys():
|
||||
"""Get all SSH keys for current user."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
keys = ssh_key_service.get_user_ssh_keys(user_id)
|
||||
return jsonify({
|
||||
'keys': [k.to_dict() for k in keys],
|
||||
'count': len(keys),
|
||||
}), 200
|
||||
|
||||
|
||||
@ssh_bp.route('/keys', methods=['POST'])
|
||||
@login_required
|
||||
def add_ssh_key():
|
||||
"""Add a new SSH public key for current user."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No JSON data provided'}), 400
|
||||
|
||||
public_key = data.get('public_key') or data.get('key')
|
||||
description = data.get('description')
|
||||
|
||||
if not public_key:
|
||||
return jsonify({'error': 'public_key is required'}), 400
|
||||
|
||||
try:
|
||||
ssh_key = ssh_key_service.add_ssh_key(
|
||||
user_id=user_id,
|
||||
public_key=public_key,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Audit log
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_KEY_ADDED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHKey',
|
||||
resource_id=ssh_key.id,
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
|
||||
return jsonify(ssh_key.to_dict()), 201
|
||||
|
||||
except SSHKeyAlreadyExistsError as e:
|
||||
return jsonify({'error': e.message, 'code': 'SSH_KEY_ALREADY_EXISTS'}), 409
|
||||
except IntegrityError:
|
||||
return jsonify({'error': 'SSH key already exists', 'code': 'SSH_KEY_ALREADY_EXISTS'}), 409
|
||||
except SSHKeyError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_ssh_key(key_id):
|
||||
"""Get a specific SSH key."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
|
||||
# Check ownership
|
||||
if ssh_key.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
return jsonify(ssh_key.to_dict()), 200
|
||||
|
||||
except SSHKeyNotFoundError:
|
||||
return jsonify({'error': 'SSH key not found'}), 404
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_ssh_key(key_id):
|
||||
"""Delete an SSH key."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
|
||||
# Check ownership
|
||||
if ssh_key.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
ssh_key_service.delete_ssh_key(key_id)
|
||||
|
||||
# Audit log
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_KEY_DELETED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHKey',
|
||||
resource_id=key_id,
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
|
||||
return jsonify({'status': 'deleted'}), 200
|
||||
|
||||
except SSHKeyNotFoundError:
|
||||
return jsonify({'error': 'SSH key not found'}), 404
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>/verify', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def verify_ssh_key(key_id):
|
||||
"""Generate or verify SSH key ownership challenge."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
|
||||
# Check ownership
|
||||
if ssh_key.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
# Handle GET request - return challenge
|
||||
if request.method == 'GET':
|
||||
challenge = ssh_key_service.generate_verification_challenge(key_id)
|
||||
return jsonify({
|
||||
'challenge_text': challenge,
|
||||
'validationText': challenge, # Backwards compatibility
|
||||
'key_id': key_id,
|
||||
}), 200
|
||||
|
||||
# Handle POST request - verify signature
|
||||
data = request.get_json() or {}
|
||||
action = data.get('action', 'verify_signature')
|
||||
|
||||
if action == 'verify_signature':
|
||||
# Verify signature
|
||||
signature = data.get('signature')
|
||||
if not signature:
|
||||
return jsonify({'error': 'signature is required'}), 400
|
||||
|
||||
try:
|
||||
verified = ssh_key_service.verify_ssh_key_ownership(key_id, signature)
|
||||
|
||||
# Audit log
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_KEY_VERIFIED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHKey',
|
||||
resource_id=key_id,
|
||||
ip_address=request.remote_addr,
|
||||
success=verified,
|
||||
)
|
||||
|
||||
return jsonify({'verified': verified}), 200
|
||||
|
||||
except Exception as e:
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_KEY_VALIDATION_FAILED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHKey',
|
||||
resource_id=key_id,
|
||||
ip_address=request.remote_addr,
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
else: # generate_challenge
|
||||
# Generate verification challenge
|
||||
challenge = ssh_key_service.generate_verification_challenge(key_id)
|
||||
return jsonify({
|
||||
'challenge_text': challenge,
|
||||
'challenge': challenge, # Both for compatibility
|
||||
}), 200
|
||||
|
||||
except SSHKeyNotFoundError:
|
||||
return jsonify({'error': 'SSH key not found'}), 404
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>/update-description', methods=['PATCH'])
|
||||
@login_required
|
||||
def update_ssh_key_description(key_id):
|
||||
"""Update SSH key description."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
data = request.get_json()
|
||||
if not data or 'description' not in data:
|
||||
return jsonify({'error': 'description is required'}), 400
|
||||
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
|
||||
# Check ownership
|
||||
if ssh_key.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
updated_key = ssh_key_service.update_ssh_key_description(
|
||||
key_id,
|
||||
data['description']
|
||||
)
|
||||
|
||||
return jsonify(updated_key.to_dict()), 200
|
||||
|
||||
except SSHKeyNotFoundError:
|
||||
return jsonify({'error': 'SSH key not found'}), 404
|
||||
|
||||
|
||||
@ssh_bp.route('/sign', methods=['POST'])
|
||||
@login_required
|
||||
def sign_certificate():
|
||||
"""Sign an SSH certificate for the current user."""
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No JSON data provided'}), 400
|
||||
|
||||
try:
|
||||
principals = data.get('principals', [])
|
||||
cert_type = data.get('cert_type', 'user')
|
||||
# Accept both 'key_id' and 'cert_id' (from CLI)
|
||||
key_id = data.get('key_id') or data.get('cert_id')
|
||||
expiry_hours = data.get('expiry_hours')
|
||||
|
||||
if not principals:
|
||||
return jsonify({'error': 'principals is required'}), 400
|
||||
|
||||
# If key_id not specified, use first verified key
|
||||
if not key_id:
|
||||
verified_keys = ssh_key_service.get_user_verified_ssh_keys(user_id)
|
||||
if not verified_keys:
|
||||
return jsonify({'error': 'No verified SSH keys found'}), 400
|
||||
key_id = verified_keys[0].id
|
||||
|
||||
# Get the SSH key
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
if ssh_key.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
if not ssh_key.verified:
|
||||
return jsonify({'error': 'SSH key is not verified'}), 400
|
||||
|
||||
# Resolve which CA to use: org DB CA > config-file CA
|
||||
db_ca = _get_org_ca_for_user(user)
|
||||
ca_private_key = db_ca.private_key if db_ca else None # None → signing service uses config
|
||||
|
||||
# Create signing request
|
||||
signing_request = SSHCertificateSigningRequest(
|
||||
ssh_public_key=ssh_key.payload,
|
||||
principals=principals,
|
||||
cert_type=cert_type,
|
||||
key_id=key_id,
|
||||
expiry_hours=int(expiry_hours) if expiry_hours else None,
|
||||
)
|
||||
|
||||
# Validate request
|
||||
validation_errors = signing_request.validate()
|
||||
if validation_errors:
|
||||
return jsonify({'errors': validation_errors}), 400
|
||||
|
||||
# Sign the certificate (pass ca_private_key=None → service loads from config)
|
||||
response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key)
|
||||
|
||||
# Persist certificate to DB
|
||||
# If user's org has no DB CA, use the system-config-ca record
|
||||
ca_for_db = db_ca or _get_or_create_system_ca()
|
||||
cert_record = _persist_certificate(
|
||||
user_id=user_id,
|
||||
ssh_key_id=key_id,
|
||||
ca=ca_for_db,
|
||||
signing_response=response,
|
||||
request_ip=request.remote_addr,
|
||||
)
|
||||
|
||||
# Audit log
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_CERT_ISSUED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHCertificate',
|
||||
resource_id=cert_record.id if cert_record else key_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'Certificate issued for principals: {", ".join(principals)}',
|
||||
)
|
||||
|
||||
result = {
|
||||
'certificate': response.certificate,
|
||||
'serial': response.serial,
|
||||
'principals': response.principals,
|
||||
'valid_after': response.valid_after.isoformat() if response.valid_after else None,
|
||||
'valid_before': response.valid_before.isoformat() if response.valid_before else None,
|
||||
}
|
||||
if cert_record:
|
||||
result['cert_id'] = str(cert_record.id)
|
||||
|
||||
return jsonify(result), 201
|
||||
|
||||
except SSHKeyNotFoundError:
|
||||
return jsonify({'error': 'SSH key not found'}), 404
|
||||
except SSHCertificateError as e:
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_CERT_FAILED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHCertificate',
|
||||
ip_address=request.remote_addr,
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_CERT_FAILED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHCertificate',
|
||||
ip_address=request.remote_addr,
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
return jsonify({'error': 'Certificate signing failed: ' + str(e)}), 500
|
||||
|
||||
|
||||
@ssh_bp.route('/certificates', methods=['GET'])
|
||||
@login_required
|
||||
def list_certificates():
|
||||
"""List all SSH certificates issued for the current user."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
certs = (
|
||||
SSHCertificate.query
|
||||
.filter_by(user_id=user_id, deleted_at=None)
|
||||
.order_by(SSHCertificate.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return jsonify({
|
||||
'certificates': [c.to_dict() for c in certs],
|
||||
'count': len(certs),
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ssh_bp.route('/certificates/<cert_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_certificate(cert_id):
|
||||
"""Get a specific issued certificate (metadata only)."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
|
||||
if not cert:
|
||||
return jsonify({'error': 'Certificate not found'}), 404
|
||||
if cert.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
# Include full certificate text in single-fetch endpoint
|
||||
data = cert.to_dict()
|
||||
data['certificate'] = cert.certificate
|
||||
return jsonify(data), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ssh_bp.route('/certificates/<cert_id>/revoke', methods=['POST'])
|
||||
@login_required
|
||||
def revoke_certificate(cert_id):
|
||||
"""Revoke an issued certificate."""
|
||||
user_id = g.current_user.id
|
||||
|
||||
data = request.get_json() or {}
|
||||
reason = data.get('reason', 'User requested revocation')
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
|
||||
if not cert:
|
||||
return jsonify({'error': 'Certificate not found'}), 404
|
||||
if cert.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
if cert.revoked:
|
||||
return jsonify({'error': 'Certificate is already revoked'}), 409
|
||||
|
||||
cert.revoke(reason=reason)
|
||||
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_CERT_REVOKED,
|
||||
user_id=user_id,
|
||||
resource_type='SSHCertificate',
|
||||
resource_id=cert_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'Revoked: {reason}',
|
||||
)
|
||||
|
||||
return jsonify({'status': 'revoked', 'cert_id': cert_id, 'reason': reason}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ssh_bp.route('/ca/public-key', methods=['GET'])
|
||||
@login_required
|
||||
def get_ca_public_key():
|
||||
"""
|
||||
Return the CA public key for this user's organization.
|
||||
|
||||
Server admins should add this key to their host's ``TrustedUserCAKeys``
|
||||
directive so that certificates issued by gatehouse are trusted.
|
||||
|
||||
Query parameters:
|
||||
format: 'openssh' (default) or 'text' — affects Content-Type only
|
||||
|
||||
Returns:
|
||||
{ "public_key": "ssh-ed25519 AAAA...",
|
||||
"fingerprint": "SHA256:...",
|
||||
"ca_name": "..." }
|
||||
"""
|
||||
user = g.current_user
|
||||
|
||||
# Try org CA first
|
||||
db_ca = _get_org_ca_for_user(user)
|
||||
if db_ca:
|
||||
return jsonify({
|
||||
'public_key': db_ca.public_key,
|
||||
'fingerprint': db_ca.fingerprint,
|
||||
'ca_name': db_ca.name,
|
||||
'source': 'db',
|
||||
}), 200
|
||||
|
||||
# Fall back to config-file CA
|
||||
try:
|
||||
from gatehouse_app.config.ssh_ca_config import get_ssh_ca_config
|
||||
import os
|
||||
cfg = get_ssh_ca_config()
|
||||
key_path = cfg.get_str('ca_key_path', '').strip() + '.pub'
|
||||
if os.path.exists(key_path):
|
||||
with open(key_path) as f:
|
||||
pub_key = f.read().strip()
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
return jsonify({
|
||||
'public_key': pub_key,
|
||||
'fingerprint': compute_ssh_fingerprint(pub_key),
|
||||
'ca_name': 'system-config-ca',
|
||||
'source': 'config',
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Could not load CA public key: {e}'}), 500
|
||||
|
||||
return jsonify({'error': 'No CA configured for this organization'}), 404
|
||||
|
||||
|
||||
Reference in New Issue
Block a user