Feat: Added CA-merged with Securid-Principals, Depart, Client-CLI

This commit is contained in:
2026-02-27 21:59:01 +05:45
parent 92fd57447d
commit b2212ab4d6
29 changed files with 3718 additions and 53 deletions
+3 -1
View File
@@ -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)
+130 -6
View File
@@ -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
+554 -2
View File
@@ -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",
)
+615
View File
@@ -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