Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy

feat: add password reset and email verification flow
feat: add org invite listing, cancellation, and invite link fallback
feat: add user suspend/unsuspend with audit logging
feat: add department certificate policy (expiry, extensions)
feat: enforce dept cert policy on SSH certificate signing
feat: wire up OIDC consent and token flow (replace mocks)
feat: rework CLI auth bridge to use frontend login flow
feat: add admin OAuth provider management (CRUD)
chore: refactor model import paths after module reorganisation
chore: clean up config, decorators, and dev tooling
This commit is contained in:
2026-03-01 16:50:27 +05:45
parent 07193a2d2e
commit a0d4e59c24
39 changed files with 2035 additions and 611 deletions
+408 -5
View File
@@ -1,6 +1,7 @@
"""Authentication endpoints."""
import json
from flask import request, session, g, jsonify
import logging
from flask import request, session, g, jsonify, current_app
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
@@ -23,6 +24,7 @@ from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.webauthn_service import WebAuthnService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.notification_service import NotificationService
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -57,6 +59,23 @@ def register():
full_name=data.get("full_name"),
)
# Send verification email
try:
from gatehouse_app.models import EmailVerificationToken
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
subject = "Verify your Gatehouse email address"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Welcome to Gatehouse! Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
except Exception as exc:
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
# Create session
user_session = AuthService.create_session(user)
@@ -179,7 +198,9 @@ def login():
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"effective_mode": org.effective_mode,
"deadline_at": org.deadline_at,
"applied_at": org.applied_at,
}
for org in policy_result.compliance_summary.orgs
],
@@ -284,7 +305,7 @@ def revoke_session(session_id):
401: Not authenticated
404: Session not found
"""
from gatehouse_app.models.session import Session
from gatehouse_app.models.user.session import Session
# Ensure session belongs to current user
user_session = Session.query.filter_by(
@@ -424,7 +445,7 @@ def verify_totp():
)
# Get user from database
from gatehouse_app.models.user import User
from gatehouse_app.models.user.user import User
user = User.query.get(user_id)
if not user:
return api_response(
@@ -475,7 +496,9 @@ def verify_totp():
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"effective_mode": org.effective_mode,
"deadline_at": org.deadline_at,
"applied_at": org.applied_at,
}
for org in policy_result.compliance_summary.orgs
],
@@ -806,7 +829,7 @@ def begin_webauthn_login():
data = schema.load(request.json)
# Find user by email
from gatehouse_app.models.user import User
from gatehouse_app.models.user.user import User
user = User.query.filter_by(
email=data["email"].lower(),
deleted_at=None
@@ -893,7 +916,7 @@ def complete_webauthn_login():
data = schema.load(request.json)
# Get user from database
from gatehouse_app.models.user import User
from gatehouse_app.models.user.user import User
user = User.query.get(user_id)
if not user:
logger.error(f"WebAuthn login complete - user not found: {user_id}")
@@ -962,7 +985,9 @@ def complete_webauthn_login():
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"effective_mode": org.effective_mode,
"deadline_at": org.deadline_at,
"applied_at": org.applied_at,
}
for org in policy_result.compliance_summary.orgs
],
@@ -1142,3 +1167,381 @@ def get_webauthn_status():
},
message="WebAuthn status retrieved successfully",
)
_pw_logger = logging.getLogger(__name__)
@api_v1_bp.route("/auth/forgot-password", methods=["POST"])
def forgot_password():
"""Request a password reset email.
Always returns 200 to avoid leaking account existence.
Request body:
email: User email address
Returns:
200: Password reset email sent (or silently no-op if email not found)
"""
from gatehouse_app.models import User, PasswordResetToken
data = request.get_json() or {}
email = (data.get("email") or "").strip().lower()
if not email:
return api_response(
success=False,
message="Email is required",
status=400,
error_type="VALIDATION_ERROR",
)
# Always return 200 — don't leak whether the email exists
user = User.query.filter_by(email=email, deleted_at=None).first()
if user:
try:
reset_token = PasswordResetToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
subject = "Reset your Gatehouse password"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"You requested a password reset for your Gatehouse account.\n\n"
f"Click the link below to reset your password (valid for 2 hours):\n"
f"{reset_link}\n\n"
f"If you did not request this, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(
to_address=user.email,
subject=subject,
body=body,
)
_pw_logger.info(f"Password reset token generated for user {user.id}")
except Exception as exc:
_pw_logger.exception(f"Error generating password reset token: {exc}")
return api_response(
data={},
message="If an account exists for this email, you will receive a password reset link shortly.",
)
@api_v1_bp.route("/auth/reset-password", methods=["POST"])
def reset_password():
"""Reset a user's password using a reset token.
Request body:
token: Password reset token from email
password: New password
password_confirm: Password confirmation
Returns:
200: Password reset successfully
400: Invalid or expired token / validation error
"""
import bcrypt as _bcrypt
from gatehouse_app.extensions import bcrypt
from gatehouse_app.models import PasswordResetToken, AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
data = request.get_json() or {}
token_value = (data.get("token") or "").strip()
new_password = data.get("password") or ""
password_confirm = data.get("password_confirm") or ""
if not token_value or not new_password:
return api_response(
success=False,
message="Token and new password are required",
status=400,
error_type="VALIDATION_ERROR",
)
if new_password != password_confirm:
return api_response(
success=False,
message="Passwords do not match",
status=400,
error_type="VALIDATION_ERROR",
)
if len(new_password) < 8:
return api_response(
success=False,
message="Password must be at least 8 characters",
status=400,
error_type="VALIDATION_ERROR",
)
reset_token = PasswordResetToken.query.filter_by(token=token_value).first()
if not reset_token or not reset_token.is_valid:
return api_response(
success=False,
message="This password reset link is invalid or has expired.",
status=400,
error_type="INVALID_TOKEN",
)
try:
user = reset_token.user
# Update the password hash on the authentication method
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.PASSWORD,
deleted_at=None,
).first()
if auth_method:
auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
from gatehouse_app.extensions import db
db.session.add(auth_method)
reset_token.consume()
_pw_logger.info(f"Password reset for user {user.id}")
return api_response(
data={},
message="Your password has been reset. You can now sign in with your new password.",
)
except Exception as exc:
_pw_logger.exception(f"Error resetting password: {exc}")
return api_response(
success=False,
message="An error occurred while resetting your password.",
status=500,
error_type="INTERNAL_ERROR",
)
@api_v1_bp.route("/auth/verify-email", methods=["POST"])
def verify_email():
"""Verify a user's email address using a verification token.
Request body:
token: Email verification token
Returns:
200: Email verified successfully
400: Invalid or expired token
"""
from gatehouse_app.models import EmailVerificationToken
data = request.get_json() or {}
token_value = (data.get("token") or "").strip()
if not token_value:
return api_response(
success=False,
message="Verification token is required",
status=400,
error_type="VALIDATION_ERROR",
)
verify_token = EmailVerificationToken.query.filter_by(token=token_value).first()
if not verify_token or not verify_token.is_valid:
return api_response(
success=False,
message="This verification link is invalid or has expired.",
status=400,
error_type="INVALID_TOKEN",
)
try:
user = verify_token.user
user.email_verified = True
from gatehouse_app.extensions import db
db.session.add(user)
verify_token.consume()
_pw_logger.info(f"Email verified for user {user.id}")
return api_response(
data={},
message="Your email has been verified. You can now sign in.",
)
except Exception as exc:
_pw_logger.exception(f"Error verifying email: {exc}")
return api_response(
success=False,
message="An error occurred while verifying your email.",
status=500,
error_type="INTERNAL_ERROR",
)
@api_v1_bp.route("/auth/resend-verification", methods=["POST"])
def resend_verification():
"""Resend email verification link.
Always returns 200 to avoid leaking account existence.
Request body:
email: User email address
Returns:
200: Verification email sent (or silently no-op)
"""
from gatehouse_app.models import User, EmailVerificationToken
data = request.get_json() or {}
email = (data.get("email") or "").strip().lower()
if not email:
return api_response(
success=False,
message="Email is required",
status=400,
error_type="VALIDATION_ERROR",
)
user = User.query.filter_by(email=email, deleted_at=None).first()
if user and not user.email_verified:
try:
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
subject = "Verify your Gatehouse email address"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(
to_address=user.email,
subject=subject,
body=body,
)
_pw_logger.info(f"Verification email sent for user {user.id}")
except Exception as exc:
_pw_logger.exception(f"Error sending verification email: {exc}")
return api_response(
data={},
message="If an account exists for this email and is not yet verified, you will receive a verification link shortly.",
)
# =============================================================================
# Account Activation (separate from email-verification)
# =============================================================================
@api_v1_bp.route("/auth/activate", methods=["POST"])
def activate_account():
"""Activate a user account via a one-time activation code.
Request body:
code the activation_key from the welcome email
Returns:
200: Account activated, session token returned
400: Missing code
404: Invalid or already-used code
"""
import secrets
from gatehouse_app.models.user.user import User
from gatehouse_app.extensions import db
data = request.get_json() or {}
code = (data.get("code") or "").strip()
if not code:
return api_response(success=False, message="Activation code is required", status=400, error_type="VALIDATION_ERROR")
user = User.query.filter_by(activation_key=code, deleted_at=None).first()
if not user:
return api_response(success=False, message="Invalid or expired activation code", status=404, error_type="NOT_FOUND")
user.activated = True
user.activation_key = None # one-time use
db.session.add(user)
db.session.commit()
user_session = AuthService.create_session(user)
_pw_logger.info(f"Account activated for user {user.id}")
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
},
message="Account activated successfully",
)
@api_v1_bp.route("/auth/resend-activation", methods=["POST"])
def resend_activation():
"""Re-send an account activation email.
Always returns 200 to avoid leaking whether an account exists.
Request body:
email user email address
"""
import secrets
from gatehouse_app.models.user.user import User
from gatehouse_app.extensions import db
data = request.get_json() or {}
email = (data.get("email") or "").strip().lower()
if not email:
return api_response(success=False, message="Email is required", status=400, error_type="VALIDATION_ERROR")
user = User.query.filter_by(email=email, deleted_at=None).first()
if user and not user.activated:
try:
code = secrets.token_urlsafe(32)
user.activation_key = code
db.session.add(user)
db.session.commit()
app_url = current_app.config.get("APP_URL", current_app.config.get("FRONTEND_URL", "http://localhost:8080"))
activate_link = f"{app_url}/activate?code={code}"
subject = "Activate your Gatehouse account"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Please activate your Gatehouse account by clicking the link below:\n"
f"{activate_link}\n\n"
f"If you did not create an account, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
_pw_logger.info(f"Activation email re-sent to {user.id}")
except Exception as exc:
_pw_logger.exception(f"Error re-sending activation email: {exc}")
return api_response(
data={},
message="If an unactivated account exists for this email, you will receive a new activation link shortly.",
)
# =============================================================================
# Token retrieval / redirect (for CLI / external tools)
# =============================================================================
@api_v1_bp.route("/auth/token", methods=["GET"])
@login_required
def get_token():
"""Return the current session token, optionally redirecting to a URL.
Query parameters:
redirect optional URL to redirect to with the token appended as
a query param: ``<redirect>?token=<token>``
Returns:
200: JSON ``{"token": "<token>"}`` (no redirect given)
302: Redirect to ``<redirect>?token=<token>``
"""
from flask import redirect as flask_redirect
token = g.current_session.token
redirect_url = request.args.get("redirect", "").strip()
if redirect_url:
sep = "&" if "?" in redirect_url else "?"
return flask_redirect(f"{redirect_url}{sep}token={token}", code=302)
return api_response(data={"token": token}, message="Token retrieved")
+144 -7
View File
@@ -1,6 +1,7 @@
"""Department endpoints."""
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from sqlalchemy.orm.attributes import flag_modified
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
@@ -422,7 +423,7 @@ def add_department_member(org_id, dept_id):
error_type="NOT_FOUND",
)
# Check if already a member
# Check if already an active member
existing = DepartmentMembership.query.filter_by(
user_id=user.id,
department_id=dept_id,
@@ -437,12 +438,23 @@ def add_department_member(org_id, dept_id):
error_type="CONFLICT",
)
# Add member
membership = DepartmentMembership(
user_id=user.id,
department_id=dept_id,
)
db.session.add(membership)
# Check for a previously soft-deleted row and resurrect it instead of inserting
soft_deleted = DepartmentMembership.query.filter(
DepartmentMembership.user_id == user.id,
DepartmentMembership.department_id == dept_id,
DepartmentMembership.deleted_at.isnot(None)
).first()
if soft_deleted:
soft_deleted.deleted_at = None
membership = soft_deleted
else:
membership = DepartmentMembership(
user_id=user.id,
department_id=dept_id,
)
db.session.add(membership)
db.session.commit()
member_dict = membership.to_dict()
@@ -560,3 +572,128 @@ def get_department_principals(org_id, dept_id):
},
message="Principals retrieved successfully",
)
# ---------------------------------------------------------------------------
# Department Certificate Policy
# ---------------------------------------------------------------------------
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/cert-policy", methods=["GET"])
@login_required
@require_admin
@full_access_required
def get_dept_cert_policy(org_id, dept_id):
"""Get the certificate issuance policy for a department (admin only)."""
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
dept = Department.query.filter_by(
id=dept_id, organization_id=org_id, deleted_at=None
).first()
if not dept:
return api_response(success=False, message="Department not found", status=404, error_type="NOT_FOUND")
policy = DepartmentCertPolicy.query.filter(
DepartmentCertPolicy.department_id == dept_id,
DepartmentCertPolicy.deleted_at.is_(None),
).first()
if policy:
data = policy.to_dict()
else:
# Return default (all standard extensions, no user expiry choice)
data = {
"department_id": str(dept_id),
"allow_user_expiry": False,
"default_expiry_hours": 1,
"max_expiry_hours": 24,
"allowed_extensions": list(STANDARD_EXTENSIONS),
"custom_extensions": [],
"all_extensions": list(STANDARD_EXTENSIONS),
"standard_extensions": list(STANDARD_EXTENSIONS),
}
return api_response(data={"cert_policy": data}, message="Certificate policy retrieved")
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/cert-policy", methods=["PUT"])
@login_required
@require_admin
@full_access_required
def set_dept_cert_policy(org_id, dept_id):
"""Create or update the certificate issuance policy for a department (admin only)."""
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
dept = Department.query.filter_by(
id=dept_id, organization_id=org_id, deleted_at=None
).first()
if not dept:
return api_response(success=False, message="Department not found", status=404, error_type="NOT_FOUND")
body = request.get_json() or {}
# Validate expiry values
default_expiry = body.get("default_expiry_hours")
max_expiry = body.get("max_expiry_hours")
if default_expiry is not None:
try:
default_expiry = int(default_expiry)
if default_expiry < 1:
raise ValueError
except (ValueError, TypeError):
return api_response(success=False, message="default_expiry_hours must be a positive integer", status=400, error_type="VALIDATION_ERROR")
if max_expiry is not None:
try:
max_expiry = int(max_expiry)
if max_expiry < 1:
raise ValueError
except (ValueError, TypeError):
return api_response(success=False, message="max_expiry_hours must be a positive integer", status=400, error_type="VALIDATION_ERROR")
if default_expiry and max_expiry and default_expiry > max_expiry:
return api_response(success=False, message="default_expiry_hours cannot exceed max_expiry_hours", status=400, error_type="VALIDATION_ERROR")
# Validate allowed_extensions — must be subset of STANDARD_EXTENSIONS
allowed_extensions = body.get("allowed_extensions")
if allowed_extensions is not None:
if not isinstance(allowed_extensions, list):
return api_response(success=False, message="allowed_extensions must be a list", status=400, error_type="VALIDATION_ERROR")
invalid_ext = [e for e in allowed_extensions if e not in STANDARD_EXTENSIONS]
if invalid_ext:
return api_response(
success=False,
message=f"Invalid standard extensions: {', '.join(invalid_ext)}. Valid: {', '.join(STANDARD_EXTENSIONS)}",
status=400,
error_type="VALIDATION_ERROR",
)
# Validate custom_extensions — plain strings
custom_extensions = body.get("custom_extensions")
if custom_extensions is not None:
if not isinstance(custom_extensions, list) or not all(isinstance(e, str) for e in custom_extensions):
return api_response(success=False, message="custom_extensions must be a list of strings", status=400, error_type="VALIDATION_ERROR")
policy = DepartmentCertPolicy.query.filter(
DepartmentCertPolicy.department_id == dept_id,
DepartmentCertPolicy.deleted_at.is_(None),
).first()
if policy is None:
policy = DepartmentCertPolicy(department_id=dept_id)
db.session.add(policy)
if "allow_user_expiry" in body:
policy.allow_user_expiry = bool(body["allow_user_expiry"])
if default_expiry is not None:
policy.default_expiry_hours = default_expiry
if max_expiry is not None:
policy.max_expiry_hours = max_expiry
if allowed_extensions is not None:
policy.allowed_extensions = list(allowed_extensions)
flag_modified(policy, "allowed_extensions")
if custom_extensions is not None:
policy.custom_extensions = list(custom_extensions)
flag_modified(policy, "custom_extensions")
db.session.commit()
return api_response(data={"cert_policy": policy.to_dict()}, message="Certificate policy saved")
+288 -22
View File
@@ -101,24 +101,24 @@ 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>
Redirects the user's browser to the Gatehouse login page so they can
authenticate using any method (password, OAuth, passkey, TOTP, etc.).
On successful login the frontend delivers the session token directly to
the CLI's local callback server.
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
3. Wait for the browser to deliver the token 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
import secrets
from urllib.parse import urlencode, quote
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(
@@ -139,26 +139,92 @@ def token_please():
error_type="INVALID_REDIRECT_URL",
)
# Store the CLI redirect URL in Redis keyed by a short-lived token so the
# frontend can retrieve it after login without it being visible in the URL.
cli_token = secrets.token_urlsafe(32)
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:
import gatehouse_app.extensions as _ext
rc = _ext.redis_client
if rc is not None:
rc.setex(f"cli_redirect:{cli_token}", _OAUTH_BRIDGE_TTL, redirect_url)
else:
logger.warning("Redis not available; passing cli_redirect directly in URL")
cli_token = None
except Exception:
cli_token = None
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
if cli_token:
# Pass an opaque token; the frontend exchanges it for the real URL via
# GET /api/v1/cli/redirect-url?token=<cli_token>
login_url = f"{frontend_url}/login?cli_token={cli_token}"
else:
# Fallback: put the redirect URL directly (still localhost-only, validated above)
login_url = f"{frontend_url}/login?cli_redirect={quote(redirect_url, safe='')}"
logger.info(f"CLI token_please: redirecting browser to Gatehouse login page")
return flask_redirect(login_url, code=302)
@api_v1_bp.route("/cli/redirect-url", methods=["GET"])
def cli_redirect_url_lookup():
"""
Exchange a short-lived cli_token for the CLI's local redirect URL.
Called by the frontend LoginPage after it detects the cli_token query
param so it can obtain the actual CLI callback URL from Redis without
exposing it in the browser URL bar.
Query parameters:
token: The cli_token issued by /token_please
Returns:
200: { "redirect_url": "http://127.0.0.1:8250/?token=" }
400: Missing token
404: Token not found or expired
"""
cli_token = request.args.get("token", "").strip()
if not cli_token:
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"),
message="token query parameter is required",
status=400,
error_type="MISSING_TOKEN",
)
# Store the CLI redirect URL so the callback can use it
_store_cli_redirect(state, redirect_url)
try:
import gatehouse_app.extensions as _ext
rc = _ext.redis_client
if rc is not None:
key = f"cli_redirect:{cli_token}"
val = rc.get(key)
if val is None:
return api_response(
success=False,
message="CLI token not found or expired",
status=404,
error_type="TOKEN_NOT_FOUND",
)
# Keep the key alive until the login actually completes (consume on use
# would break multi-step auth like TOTP), so we leave it as-is.
redirect_url = val.decode() if isinstance(val, bytes) else val
return api_response(data={"redirect_url": redirect_url})
except Exception as e:
logger.error(f"cli_redirect_url_lookup error: {e}")
return api_response(
success=False,
message="Internal error looking up CLI token",
status=500,
error_type="INTERNAL_ERROR",
)
logger.info(f"CLI token_please: provider={provider}, redirect_url={redirect_url}, redirecting to OAuth")
return flask_redirect(auth_url, code=302)
return api_response(
success=False,
message="Redis not available",
status=503,
error_type="SERVICE_UNAVAILABLE",
)
# =============================================================================
@@ -175,7 +241,7 @@ def list_providers():
200: List of providers with their configuration status
401: Not authenticated
"""
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
# Check app-level provider configs (ApplicationProviderConfig)
@@ -1173,3 +1239,203 @@ def _get_provider_endpoints(provider_type: AuthMethodType):
"UNSUPPORTED_PROVIDER",
400,
)
# =============================================================================
# Admin: Application-level OAuth Provider Management
# =============================================================================
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
@login_required
def admin_list_app_providers():
"""List all application-level OAuth provider configurations (admin only).
Returns:
200: List of providers with client_id and enabled status
401: Not authenticated
403: Not an admin
"""
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
# Verify caller is admin in any org
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
).all()
if not admin_memberships:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
PROVIDERS = [
{"id": "google", "name": "Google"},
{"id": "github", "name": "GitHub"},
{"id": "microsoft", "name": "Microsoft"},
]
db_configs = {
c.provider_type: c
for c in ApplicationProviderConfig.query.all()
}
result = []
for p in PROVIDERS:
cfg = db_configs.get(p["id"])
result.append({
"id": p["id"],
"name": p["name"],
"is_configured": cfg is not None,
"is_enabled": cfg.is_enabled if cfg else False,
"client_id": cfg.client_id if cfg else None,
})
return api_response(
data={"providers": result},
message="OAuth providers retrieved successfully",
)
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["PUT"])
@login_required
def admin_configure_app_provider(provider: str):
"""Create or update an application-level OAuth provider config (admin only).
Args:
provider: Provider type (google, github, microsoft)
Request body:
client_id: OAuth client ID
client_secret: OAuth client secret (optional — omit to keep existing)
is_enabled: Whether the provider is enabled (default: true)
Returns:
200: Provider configuration updated
400: Validation error
401: Not authenticated
403: Not an admin
"""
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.extensions import db
SUPPORTED = ["google", "github", "microsoft"]
if provider not in SUPPORTED:
return api_response(
success=False,
message=f"Unsupported provider. Must be one of: {', '.join(SUPPORTED)}",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify caller is admin in any org
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
).all()
if not admin_memberships:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
data = request.json or {}
client_id = (data.get("client_id") or "").strip()
client_secret = (data.get("client_secret") or "").strip()
is_enabled = data.get("is_enabled", True)
if not client_id:
return api_response(
success=False,
message="client_id is required",
status=400,
error_type="VALIDATION_ERROR",
)
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
if cfg:
cfg.client_id = client_id
if client_secret:
cfg.set_client_secret(client_secret)
cfg.is_enabled = bool(is_enabled)
db.session.commit()
else:
cfg = ApplicationProviderConfig(
provider_type=provider,
client_id=client_id,
is_enabled=bool(is_enabled),
)
if client_secret:
cfg.set_client_secret(client_secret)
db.session.add(cfg)
db.session.commit()
return api_response(
data={
"provider": {
"id": provider,
"client_id": cfg.client_id,
"is_enabled": cfg.is_enabled,
}
},
message=f"{provider.capitalize()} OAuth provider configured successfully",
)
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["DELETE"])
@login_required
def admin_delete_app_provider(provider: str):
"""Delete an application-level OAuth provider config (admin only).
Args:
provider: Provider type (google, github, microsoft)
Returns:
200: Provider configuration deleted
404: Provider not found
401: Not authenticated
403: Not an admin
"""
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.extensions import db
# Verify caller is admin in any org
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
).all()
if not admin_memberships:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
if not cfg:
return api_response(
success=False,
message=f"Provider '{provider}' is not configured",
status=404,
error_type="NOT_FOUND",
)
db.session.delete(cfg)
db.session.commit()
return api_response(
message=f"{provider.capitalize()} OAuth provider configuration removed",
)
+120 -22
View File
@@ -381,6 +381,7 @@ def update_member_role(org_id, user_id):
@api_v1_bp.route("/organizations/<org_id>/audit-logs", methods=["GET"])
@login_required
@require_admin
@full_access_required
def get_organization_audit_logs(org_id):
"""
@@ -397,7 +398,7 @@ def get_organization_audit_logs(org_id):
403: Not a member / insufficient permissions
404: Organization not found
"""
from gatehouse_app.models.audit_log import AuditLog
from gatehouse_app.models.auth.audit_log import AuditLog
# Ensure org exists and user is a member (full_access_required handles this)
OrganizationService.get_organization_by_id(org_id)
@@ -492,7 +493,7 @@ def create_org_invite(org_id):
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
invite_link = f"{app_url}/invite?token={invite.token}"
NotificationService._send_email(
email_sent = NotificationService._send_email(
to_address=email,
subject=f"You're invited to join {org.name} on Gatehouse",
body=(
@@ -503,13 +504,103 @@ def create_org_invite(org_id):
),
)
# In dev mode email may not be configured — always log the link so it's findable
import logging
if not email_sent:
logging.getLogger(__name__).warning(
f"[INVITE LINK] Email not sent (EMAIL_ENABLED=False or SMTP down). "
f"Invite for {email}{invite_link}"
)
else:
logging.getLogger(__name__).info(
f"[INVITE] Email sent successfully to {email}"
)
response_data = {
"invite": {
"id": invite.id,
"email": invite.email,
"role": invite.role,
"expires_at": invite.expires_at.isoformat() + "Z",
# Only include invite_link when email delivery failed — signals frontend to show copy dialog
**({"invite_link": invite_link} if not email_sent else {}),
}
}
return api_response(
data={"invite": {"id": invite.id, "email": invite.email, "role": invite.role, "expires_at": invite.expires_at.isoformat() + "Z"}},
data=response_data,
message="Invite sent successfully",
status=201,
)
@api_v1_bp.route("/organizations/<org_id>/invites", methods=["GET"])
@login_required
@require_admin
def list_org_invites(org_id):
"""List pending invite tokens for an organization.
Returns:
200: List of invites
403: Not an admin
404: Organization not found
"""
from gatehouse_app.models import OrgInviteToken, 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)
invites = (
OrgInviteToken.query.filter_by(organization_id=org_id)
.filter(OrgInviteToken.accepted_at == None)
.filter(OrgInviteToken.deleted_at == None)
.all()
)
def invite_to_dict(inv):
return {
"id": inv.id,
"email": inv.email,
"role": inv.role,
"invited_by_id": inv.invited_by_id,
"created_at": inv.created_at.isoformat() + "Z",
"expires_at": inv.expires_at.isoformat() + "Z",
}
return api_response(
data={"invites": [invite_to_dict(i) for i in invites]},
message="Invites retrieved",
)
@api_v1_bp.route("/organizations/<org_id>/invites/<invite_id>", methods=["DELETE"])
@login_required
@require_admin
def cancel_org_invite(org_id, invite_id):
"""Cancel (soft-delete) an organization invite.
Returns:
200: Invite cancelled
403: Not an admin
404: Invite not found
"""
from gatehouse_app.models import OrgInviteToken, 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)
invite = OrgInviteToken.query.filter_by(id=invite_id, organization_id=org_id, deleted_at=None).first()
if not invite:
return api_response(success=False, message="Invite not found", status=404)
# Soft delete the invite so it's no longer usable
invite.delete(soft=True)
return api_response(data={}, message="Invite cancelled")
@api_v1_bp.route("/invites/<token>", methods=["GET"])
def get_invite(token):
"""Get invite details by token.
@@ -518,17 +609,20 @@ def get_invite(token):
200: Invite details (org name, email)
400: Invalid or expired token
"""
from gatehouse_app.models import OrgInviteToken
from gatehouse_app.models import OrgInviteToken, User
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")
user_exists = User.query.filter_by(email=invite.email, deleted_at=None).first() is not None
return api_response(
data={
"email": invite.email,
"organization": {"id": invite.organization_id, "name": invite.organization.name},
"role": invite.role,
"user_exists": user_exists,
},
message="Invite found",
)
@@ -617,12 +711,14 @@ def accept_invite(token):
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_org_clients(org_id):
"""List OIDC clients for an organization.
Returns:
200: List of OIDC clients
403: Not a member
403: Not an admin
404: Organization not found
"""
from gatehouse_app.models import OIDCClient, Organization
@@ -838,16 +934,18 @@ def get_system_audit_logs():
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
from gatehouse_app.models.auth.audit_log import AuditLog
from gatehouse_app.models.organization.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"
# Check if the user is an admin or owner of any org to grant admin-level access
is_admin = OrganizationMember.query.filter(
OrganizationMember.user_id == current_user.id,
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
OrganizationMember.deleted_at == None,
).first() is not None
query = AuditLog.query
@@ -905,7 +1003,7 @@ def get_my_audit_logs():
per_page results per page (default 50, max 200)
action filter by AuditAction value
"""
from gatehouse_app.models.audit_log import AuditLog
from gatehouse_app.models.auth.audit_log import AuditLog
current_user = g.current_user
page = max(1, int(request.args.get("page", 1)))
@@ -947,8 +1045,8 @@ def list_organization_roles(org_id):
401: Not authenticated
404: Organization not found
"""
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
if not org:
@@ -996,7 +1094,7 @@ def assign_role_to_member(org_id, role_name):
403: Not an admin/owner
404: Org or member not found
"""
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.extensions import db
try:
@@ -1040,7 +1138,7 @@ def remove_role_from_member(org_id, role_name, user_id):
403: Not an admin/owner
404: Org or member not found
"""
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.extensions import db
try:
@@ -1074,8 +1172,8 @@ def list_org_cas(org_id):
403: Not admin/owner
404: Org not found
"""
from gatehouse_app.models.ca import CA
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.ssh_ca.ca import CA
from gatehouse_app.models.organization.organization import Organization
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
if not org:
@@ -1104,8 +1202,8 @@ def update_org_ca(org_id, ca_id):
403: Not admin/owner
404: Org or CA not found
"""
from gatehouse_app.models.ca import CA
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.ssh_ca.ca import CA
from gatehouse_app.models.organization.organization import Organization
from marshmallow import Schema, fields, validate, ValidationError
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
@@ -1192,8 +1290,8 @@ def create_org_ca(org_id):
403: Not admin/owner
404: Org not found
"""
from gatehouse_app.models.ca import CA, KeyType
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.ssh_ca.ca import CA, KeyType
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
from marshmallow import Schema, fields as ma_fields, validate, ValidationError as MaValidationError
from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey
@@ -1227,7 +1325,7 @@ def create_org_ca(org_id):
)
# Enforce one CA per type per org
from gatehouse_app.models.ca import CaType
from gatehouse_app.models.ssh_ca.ca import CaType
ca_type_val = data["ca_type"]
existing_type = CA.query.filter_by(
organization_id=org_id, deleted_at=None
+35 -11
View File
@@ -195,20 +195,46 @@ def get_org_mfa_compliance(org_id):
limit = min(int(request.args.get("limit", 100)), 100)
offset = int(request.args.get("offset", 0))
page = int(request.args.get("page", 1))
page_size = min(int(request.args.get("page_size", limit)), 100)
effective_offset = offset if request.args.get("offset") else (page - 1) * page_size
compliance_list = MfaPolicyService.get_org_compliance_list(
organization_id=org_id,
status=status,
limit=limit,
offset=offset,
limit=page_size,
offset=effective_offset,
)
def format_member(c):
"""Normalize compliance record to UI-expected shape."""
if isinstance(c, dict):
return {
"user_id": c.get("user_id"),
"user_email": c.get("email"),
"user_name": c.get("full_name"),
"status": c.get("status"),
"deadline_at": c.get("deadline_at"),
"compliant_at": c.get("compliant_at"),
"last_notified_at": c.get("notified_at"),
}
return {
"user_id": getattr(c, "user_id", None),
"user_email": getattr(c, "email", None),
"user_name": getattr(c, "full_name", None),
"status": getattr(c, "status", None),
"deadline_at": getattr(c, "deadline_at", None),
"compliant_at": getattr(c, "compliant_at", None),
"last_notified_at": getattr(c, "notified_at", None),
}
return api_response(
data={
"compliance": compliance_list,
"members": [format_member(c) for c in compliance_list],
"count": len(compliance_list),
"limit": limit,
"offset": offset,
"page": page,
"page_size": page_size,
},
message="Compliance records retrieved successfully",
)
@@ -325,12 +351,10 @@ def get_my_mfa_compliance():
return api_response(
data={
"mfa_compliance": {
"overall_status": compliance_summary.overall_status,
"missing_methods": compliance_summary.missing_methods,
"deadline_at": compliance_summary.deadline_at,
"orgs": orgs,
}
"overall_status": compliance_summary.overall_status,
"missing_methods": compliance_summary.missing_methods,
"deadline_at": compliance_summary.deadline_at,
"orgs": orgs,
},
message="MFA compliance retrieved successfully",
)
+22 -13
View File
@@ -458,12 +458,22 @@ def add_principal_member(org_id, principal_id):
error_type="CONFLICT",
)
# Add member
membership = PrincipalMembership(
user_id=user.id,
principal_id=principal_id,
)
db.session.add(membership)
soft_deleted = PrincipalMembership.query.filter(
PrincipalMembership.user_id == user.id,
PrincipalMembership.principal_id == principal_id,
PrincipalMembership.deleted_at.isnot(None)
).first()
if soft_deleted:
soft_deleted.deleted_at = None
membership = soft_deleted
else:
membership = PrincipalMembership(
user_id=user.id,
principal_id=principal_id,
)
db.session.add(membership)
db.session.commit()
member_dict = membership.to_dict()
@@ -665,7 +675,7 @@ def link_principal_to_department(org_id, principal_id, dept_id):
soft_deleted = DepartmentPrincipal.query.filter(
DepartmentPrincipal.department_id == dept_id,
DepartmentPrincipal.principal_id == principal_id,
DepartmentPrincipal.deleted_at != None, # noqa: E711
DepartmentPrincipal.deleted_at.isnot(None),
).first()
try:
@@ -678,13 +688,8 @@ def link_principal_to_department(org_id, principal_id, dept_id):
)
db.session.add(link)
db.session.commit()
except Exception as e:
except Exception:
db.session.rollback()
from gatehouse_app.extensions import db as _db
try:
_db.session.rollback()
except Exception:
pass
return api_response(
success=False,
message="Failed to link principal to department",
@@ -693,6 +698,10 @@ def link_principal_to_department(org_id, principal_id, dept_id):
)
return api_response(
data={
"principal": principal.to_dict(),
"department": dept.to_dict(),
},
message="Principal linked to department successfully",
status=201,
)
+127 -10
View File
@@ -30,7 +30,7 @@ ssh_ca_service = SSHCASigningService()
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
from gatehouse_app.models.ssh_ca.ca import CA
org_ids = [m.organization_id for m in user.organization_memberships]
if not org_ids:
return None
@@ -51,7 +51,7 @@ def _get_or_create_system_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.models.ssh_ca.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
@@ -132,8 +132,8 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
try:
from gatehouse_app.extensions import db
from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ca import CertType
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ssh_ca.ca import CertType
try:
resolved_cert_type = CertType(cert_type_str)
@@ -172,6 +172,87 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
def _get_merged_dept_cert_policy(user_id):
"""Return a merged cert policy view for the given user across all their departments.
Rules for merging when a user belongs to multiple departments:
- ``allow_user_expiry``: True only if ALL departments allow it.
- ``default_expiry_hours``: minimum across departments (most restrictive).
- ``max_expiry_hours``: minimum across departments (most restrictive).
- ``extensions``: intersection only extensions allowed by ALL departments.
Returns a plain dict with keys:
allow_user_expiry, default_expiry_hours, max_expiry_hours, extensions
Or None if the user has no department memberships or no policies are configured.
"""
from gatehouse_app.models.organization.department import DepartmentMembership
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
memberships = DepartmentMembership.query.filter_by(user_id=user_id, deleted_at=None).all()
dept_ids = [m.department_id for m in memberships if m.department and m.department.deleted_at is None]
if not dept_ids:
return None
policies = DepartmentCertPolicy.query.filter(
DepartmentCertPolicy.department_id.in_(dept_ids),
DepartmentCertPolicy.deleted_at.is_(None),
).all()
if not policies:
return None
allow_user_expiry = all(p.allow_user_expiry for p in policies)
default_expiry_hours = min(p.default_expiry_hours for p in policies)
max_expiry_hours = min(p.max_expiry_hours for p in policies)
# Intersection of all_extensions() across policies
ext_sets = [set(p.all_extensions()) for p in policies]
extensions = list(ext_sets[0].intersection(*ext_sets[1:]))
return {
"allow_user_expiry": allow_user_expiry,
"default_expiry_hours": default_expiry_hours,
"max_expiry_hours": max_expiry_hours,
"extensions": extensions,
}
@ssh_bp.route('/dept-cert-policy', methods=['GET'])
@login_required
def get_my_dept_cert_policy():
"""Return the merged department certificate policy for the current user.
Admins always get allow_user_expiry=True so the frontend shows the expiry
picker for them regardless of the member-facing toggle setting.
"""
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
from gatehouse_app.utils.constants import OrganizationRole
user = g.current_user
user_id = user.id
# Check if caller is an org admin/owner
is_org_admin = OrganizationMember.query.filter(
OrganizationMember.user_id == user_id,
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
OrganizationMember.deleted_at == None,
).first() is not None
policy = _get_merged_dept_cert_policy(user_id)
if policy is None:
policy = {
"allow_user_expiry": is_org_admin, # admins default to True even without a dept policy
"default_expiry_hours": 1,
"max_expiry_hours": 24,
"extensions": list(STANDARD_EXTENSIONS),
}
elif is_org_admin:
# Override allow_user_expiry for admins — they can always pick
policy = {**policy, "allow_user_expiry": True}
return api_response(data={"policy": policy}, message="Certificate policy retrieved")
@ssh_bp.route('/keys', methods=['GET'])
@login_required
def list_ssh_keys():
@@ -375,6 +456,16 @@ def sign_certificate():
user = g.current_user
user_id = user.id
# ── Check account suspension ──────────────────────────────────────────────
from gatehouse_app.utils.constants import UserStatus
if user.status == UserStatus.SUSPENDED:
return api_response(
success=False,
message="Your account is suspended. Contact an administrator.",
status=403,
error_type="ACCOUNT_SUSPENDED",
)
data = request.get_json()
if not data:
return api_response(success=False, message="No JSON data provided", status=400, error_type="BAD_REQUEST")
@@ -385,9 +476,9 @@ def sign_certificate():
expiry_hours = data.get('expiry_hours')
# ── Resolve which principals the user is allowed to use ──────────────────
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.principal import Principal, PrincipalMembership
from gatehouse_app.models.department import DepartmentMembership, DepartmentPrincipal
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal
from gatehouse_app.utils.constants import OrganizationRole
allowed_principal_names = set()
@@ -468,12 +559,38 @@ def sign_certificate():
db_ca = _get_org_ca_for_user(user)
ca_private_key = db_ca.private_key if db_ca else None
# Determine if the caller is an org admin/owner (admins can always choose expiry)
is_org_admin = any(
om.role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
for om in memberships
if om.organization and om.organization.deleted_at is None
)
# ── Apply department certificate policy ───────────────────────────────────
dept_policy = _get_merged_dept_cert_policy(user_id)
if dept_policy:
if is_org_admin:
# Admins can always choose their own expiry, but still capped at dept max
if expiry_hours is not None:
expiry_hours = min(int(expiry_hours), dept_policy["max_expiry_hours"])
elif not dept_policy["allow_user_expiry"]:
# Regular members: ignore user-requested expiry; use dept default
expiry_hours = dept_policy["default_expiry_hours"]
else:
# Regular members allowed to pick, cap at dept maximum
if expiry_hours is not None:
expiry_hours = min(int(expiry_hours), dept_policy["max_expiry_hours"])
policy_extensions = dept_policy["extensions"]
else:
policy_extensions = None # let signing service use its own defaults
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,
extensions=policy_extensions,
)
validation_errors = signing_request.validate()
if validation_errors:
@@ -547,7 +664,7 @@ def list_certificates():
user_id = g.current_user.id
try:
from gatehouse_app.models.ssh_certificate import SSHCertificate
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
certs = (
SSHCertificate.query
.filter_by(user_id=user_id, deleted_at=None)
@@ -577,7 +694,7 @@ def get_certificate(cert_id):
user_id = g.current_user.id
try:
from gatehouse_app.models.ssh_certificate import SSHCertificate
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
if not cert:
return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
@@ -600,7 +717,7 @@ def revoke_certificate(cert_id):
reason = data.get('reason', 'User requested revocation')
try:
from gatehouse_app.models.ssh_certificate import SSHCertificate
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
if not cert:
return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
+274 -17
View File
@@ -142,18 +142,33 @@ def change_password():
@full_access_required
def get_my_organizations():
"""
Get all organizations current user is a member of.
Get all organizations current user is a member of, including the user's role.
Returns:
200: List of organizations
200: List of organizations with role
401: Not authenticated
"""
organizations = UserService.get_user_organizations(g.current_user)
from gatehouse_app.models.organization.organization_member import OrganizationMember
user = g.current_user
memberships = OrganizationMember.query.filter_by(
user_id=user.id,
deleted_at=None,
).all()
orgs = []
for membership in memberships:
org = membership.organization
if not org or org.deleted_at is not None:
continue
org_dict = org.to_dict()
org_dict["role"] = membership.role.value if hasattr(membership.role, "value") else str(membership.role)
orgs.append(org_dict)
return api_response(
data={
"organizations": [org.to_dict() for org in organizations],
"count": len(organizations),
"organizations": orgs,
"count": len(orgs),
},
message="Organizations retrieved successfully",
)
@@ -179,9 +194,9 @@ def get_my_principals():
}]
}
"""
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.principal import Principal, PrincipalMembership
from gatehouse_app.models.department import DepartmentMembership, DepartmentPrincipal
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal
from gatehouse_app.utils.constants import OrganizationRole
user = g.current_user
@@ -202,7 +217,9 @@ def get_my_principals():
is_admin = role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
# Collect the user's effective principals for this org
effective_principal_ids = set()
# Track direct vs via-department separately
direct_principal_ids = set()
via_dept_principal_ids = set()
# Direct memberships
direct = PrincipalMembership.query.filter_by(
@@ -211,7 +228,7 @@ def get_my_principals():
).all()
for pm in direct:
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
effective_principal_ids.add(pm.principal_id)
direct_principal_ids.add(pm.principal_id)
# Via department
dept_memberships = DepartmentMembership.query.filter_by(
@@ -226,7 +243,9 @@ def get_my_principals():
).all()
for dp in dept_principals:
if dp.principal and dp.principal.deleted_at is None:
effective_principal_ids.add(dp.principal_id)
via_dept_principal_ids.add(dp.principal_id)
effective_principal_ids = direct_principal_ids | via_dept_principal_ids
# Fetch principal objects
my_principals = []
@@ -235,7 +254,16 @@ def get_my_principals():
Principal.id.in_(list(effective_principal_ids)),
Principal.deleted_at == None,
).all()
my_principals = [{"id": p.id, "name": p.name, "description": p.description} for p in my_p]
my_principals = [
{
"id": p.id,
"name": p.name,
"description": p.description,
# direct=True means removable via API; False=inherited via department
"direct": p.id in direct_principal_ids,
}
for p in my_p
]
# For admins/owners: also return all principals in the org
all_principals = []
@@ -263,6 +291,7 @@ def get_my_principals():
@api_v1_bp.route("/admin/users", methods=["GET"])
@login_required
@full_access_required
def admin_list_users():
"""List all users the caller has admin rights to see.
@@ -275,8 +304,8 @@ def admin_list_users():
page page number (default 1)
per_page page size (default 50, max 200)
"""
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.user import User as _User
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user.user import User as _User
from gatehouse_app.extensions import db as _db
from sqlalchemy import or_
@@ -355,11 +384,12 @@ def admin_list_users():
@api_v1_bp.route("/admin/users/<user_id>", methods=["GET"])
@login_required
@full_access_required
def admin_get_user(user_id):
"""Get a single user's profile (admin view with SSH keys)."""
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.user import User as _User
from gatehouse_app.models.ssh_key import SSHKey
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user.user import User as _User
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
caller = g.current_user
@@ -388,3 +418,230 @@ def admin_get_user(user_id):
},
message="User retrieved",
)
@api_v1_bp.route("/admin/users/<user_id>/suspend", methods=["POST"])
@login_required
@full_access_required
def admin_suspend_user(user_id):
"""Suspend a user account (blocks CA issuance and login).
The caller must be an OWNER or ADMIN of an organization the target user belongs to.
"""
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user.user import User as _User
from gatehouse_app.extensions import db as _db
from gatehouse_app.utils.constants import UserStatus, AuditAction
from gatehouse_app.services.audit_service import AuditService
caller = g.current_user
target = _User.query.filter_by(id=user_id, deleted_at=None).first()
if not target:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
if target.id == caller.id:
return api_response(success=False, message="Cannot suspend yourself", status=400, error_type="BAD_REQUEST")
# Verify caller has admin access to a shared org
target_org_ids = {m.organization_id for m in target.organization_memberships if m.deleted_at is None}
admin_in_shared_org = OrganizationMember.query.filter(
OrganizationMember.user_id == caller.id,
OrganizationMember.organization_id.in_(target_org_ids),
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
OrganizationMember.deleted_at == None,
).first()
if not admin_in_shared_org:
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
if target.status == UserStatus.SUSPENDED:
return api_response(success=False, message="User is already suspended", status=409, error_type="CONFLICT")
target.status = UserStatus.SUSPENDED
_db.session.commit()
AuditService.log_action(
action=AuditAction.USER_SUSPEND,
user_id=caller.id,
organization_id=admin_in_shared_org.organization_id,
resource_type="user",
resource_id=str(target.id),
description=f"Admin suspended user {target.email}",
metadata={"target_user_id": str(target.id), "target_email": target.email},
)
return api_response(data={"user": target.to_dict()}, message="User suspended successfully")
@api_v1_bp.route("/admin/users/<user_id>/unsuspend", methods=["POST"])
@login_required
@full_access_required
def admin_unsuspend_user(user_id):
"""Restore a suspended user account to active status."""
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user.user import User as _User
from gatehouse_app.extensions import db as _db
from gatehouse_app.utils.constants import UserStatus, AuditAction
from gatehouse_app.services.audit_service import AuditService
caller = g.current_user
target = _User.query.filter_by(id=user_id, deleted_at=None).first()
if not target:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
# Verify caller has admin access to a shared org
target_org_ids = {m.organization_id for m in target.organization_memberships if m.deleted_at is None}
admin_in_shared_org = OrganizationMember.query.filter(
OrganizationMember.user_id == caller.id,
OrganizationMember.organization_id.in_(target_org_ids),
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
OrganizationMember.deleted_at == None,
).first()
if not admin_in_shared_org:
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
if target.status != UserStatus.SUSPENDED:
return api_response(success=False, message="User is not suspended", status=409, error_type="CONFLICT")
target.status = UserStatus.ACTIVE
_db.session.commit()
AuditService.log_action(
action=AuditAction.USER_UNSUSPEND,
user_id=caller.id,
organization_id=admin_in_shared_org.organization_id,
resource_type="user",
resource_id=str(target.id),
description=f"Admin unsuspended user {target.email}",
metadata={"target_user_id": str(target.id), "target_email": target.email},
)
return api_response(data={"user": target.to_dict()}, message="User unsuspended successfully")
@api_v1_bp.route("/users/me/invites", methods=["GET"])
@login_required
def get_my_pending_invites():
"""Return pending (unaccepted, non-expired) invitations for the current user's email."""
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
from datetime import datetime, timezone
user = g.current_user
now = datetime.now(timezone.utc)
invites = OrgInviteToken.query.filter(
OrgInviteToken.email == user.email,
OrgInviteToken.accepted_at.is_(None),
OrgInviteToken.expires_at > now,
OrgInviteToken.deleted_at.is_(None),
).all()
return api_response(
data={
"invites": [
{
"token": i.token,
"organization": {"id": str(i.organization_id), "name": i.organization.name},
"role": i.role,
"expires_at": i.expires_at.isoformat(),
}
for i in invites
]
},
message="Pending invitations retrieved",
)
@api_v1_bp.route("/users/me/memberships", methods=["GET"])
@login_required
def get_my_memberships():
"""Return the current user's department and principal memberships across all orgs.
Returns:
200: {
orgs: [{
org_id, org_name, role,
departments: [{id, name, description}],
principals: [{id, name, description, via_department: bool}]
}]
}
"""
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal, Department
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
user = g.current_user
memberships = OrganizationMember.query.filter_by(
user_id=user.id,
deleted_at=None,
).all()
orgs_result = []
for membership in memberships:
org = membership.organization
if not org or org.deleted_at is not None:
continue
# Departments the user belongs to
dept_memberships = DepartmentMembership.query.filter_by(
user_id=user.id,
deleted_at=None,
).all()
user_depts = [
dm.department for dm in dept_memberships
if dm.department
and dm.department.organization_id == org.id
and dm.department.deleted_at is None
]
# Principals: direct
direct_pm = PrincipalMembership.query.filter_by(
user_id=user.id,
deleted_at=None,
).all()
direct_principal_ids = {
pm.principal_id for pm in direct_pm
if pm.principal
and pm.principal.organization_id == org.id
and pm.principal.deleted_at is None
}
# Principals: via department
via_dept_principal_ids = set()
for dept in user_depts:
for dp in DepartmentPrincipal.query.filter_by(department_id=dept.id, deleted_at=None).all():
if dp.principal and dp.principal.deleted_at is None:
via_dept_principal_ids.add(dp.principal_id)
all_principal_ids = direct_principal_ids | via_dept_principal_ids
principals_list = []
if all_principal_ids:
for p in Principal.query.filter(
Principal.id.in_(list(all_principal_ids)),
Principal.deleted_at == None,
).all():
principals_list.append({
"id": str(p.id),
"name": p.name,
"description": p.description,
"via_department": p.id not in direct_principal_ids,
})
role = membership.role
orgs_result.append({
"org_id": str(org.id),
"org_name": org.name,
"role": role.value if hasattr(role, "value") else role,
"departments": [
{"id": str(d.id), "name": d.name, "description": d.description}
for d in user_depts
],
"principals": principals_list,
})
return api_response(
data={"orgs": orgs_result},
message="Memberships retrieved",
)