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
+6 -2
View File
@@ -40,5 +40,9 @@ LOG_TO_STDOUT=True
RATELIMIT_ENABLED=True
RATELIMIT_STORAGE_URL=redis://localhost:6379/1
# Testing
TESTING=False
# SSH CA
# Path to CA private key file (alternative to SSH_CA_PRIVATE_KEY env var)
SSH_CA_KEY_PATH=/path/to/ca-users
# Or set the key content directly (takes priority over SSH_CA_KEY_PATH):
# SSH_CA_PRIVATE_KEY=
+185 -217
View File
@@ -1,6 +1,5 @@
#!/usr/bin/python3
import base64
from datetime import datetime
import os
import sys
import webbrowser
@@ -17,7 +16,6 @@ from sshkey_tools.cert import SSHCertificate
import logging
import coloredlogs
import subprocess
import base64
# Load environment variables from the .env file
load_dotenv()
@@ -36,11 +34,18 @@ CHALLENGE_SIG_FILE_PATH = "/tmp/challenge.txt.sig"
logger = logging.getLogger(__name__)
coloredlogs.install(level='DEBUG', logger=logger, fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
token = ""
def auth_headers(content_type="application/json"):
"""Return auth headers using the current cached token."""
return {"Authorization": f"Bearer {token}", "Content-Type": content_type}
class MyServer(BaseHTTPRequestHandler):
def do_GET(self):
"""Handle GET requests and process token reception."""
global server_done, token
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
@@ -52,9 +57,10 @@ class MyServer(BaseHTTPRequestHandler):
parsed_url = urlparse(self.path)
query_data = dict(parse_qsl(parsed_url.query))
token = query_data.get('token')
received_token = query_data.get('token')
if token:
if received_token:
token = received_token
server_done = True
logger.info("Token received")
save_token_to_cache(token)
@@ -87,37 +93,42 @@ def clear_token_cache():
logger.info("No cached token found.")
def decode_and_validate_token(token):
"""Decode the JWT and validate its claims."""
"""Decode the JWT and validate its claims.
Returns True if the token is a valid, non-expired JWT.
Returns False if the token is not a JWT (e.g. opaque session token)
or if it has expired — callers should then fall back to /auth/me.
"""
try:
decoded_token = jwt.decode(token, options={"verify_signature": False})
logger.debug("debug_jwt - Decoded token ok")
iat = decoded_token.get('iat')
exp = decoded_token.get('exp')
if iat is None or exp is None:
raise ValueError("Token must contain 'iat' and 'exp' claims.")
iat_utc = datetime.datetime.fromtimestamp(iat, pytz.UTC).isoformat()
exp_utc = datetime.datetime.fromtimestamp(exp, pytz.UTC).isoformat()
logger.debug(f"debug_jwt - iat (UTC ISO): {iat_utc}")
logger.debug(f"debug_jwt - exp (UTC ISO): {exp_utc}")
now = datetime.datetime.now(pytz.UTC)
if datetime.datetime.fromtimestamp(iat, pytz.UTC) > now:
logger.debug(f"debug_jwt - Token 'iat' is after the current time.")
if datetime.datetime.fromtimestamp(exp, pytz.UTC) < now:
logger.debug(f"debug_jwt - Token 'exp' is before the current time.")
return False # Token has expired
return True # Token is valid
except Exception as e:
logger.debug(f"Token validation failed: {e}")
except jwt.exceptions.DecodeError:
# Not a JWT — likely an opaque session token; let /auth/me handle it.
return False
except Exception as e:
logger.debug(f"Unexpected JWT decode error: {e}")
return False
iat = decoded_token.get('iat')
exp = decoded_token.get('exp')
if iat is None or exp is None:
logger.debug("JWT is missing 'iat' or 'exp' claims — treating as invalid.")
return False
now = datetime.datetime.now(pytz.UTC)
exp_dt = datetime.datetime.fromtimestamp(exp, pytz.UTC)
iat_dt = datetime.datetime.fromtimestamp(iat, pytz.UTC)
logger.debug(f"JWT iat={iat_dt.isoformat()} exp={exp_dt.isoformat()}")
if exp_dt < now:
logger.debug("JWT has expired.")
return False
if iat_dt > now:
logger.debug("JWT 'iat' is in the future — clock skew?")
return True
def request_token():
global server_done, token
@@ -177,57 +188,80 @@ def request_token():
return token
def get_activated_ssh_key():
"""Retrieve the list of SSH keys and return the first verified key."""
"""Retrieve the list of SSH keys and return the ID of a verified key."""
try:
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=headers)
if response.status_code == 200:
keys = response.json().get('keys', [])
verified_keys = [key for key in keys if key['verified']]
if not verified_keys:
logger.error("No verified SSH keys found for the user.")
exit(1)
if len(verified_keys) > 1:
# If running interactively, let the user pick; otherwise use the most recently added key
if sys.stdout.isatty():
print("\nMultiple verified SSH keys found. Please choose one:")
for i, k in enumerate(verified_keys):
print(f" [{i+1}] {k['id'][:8]}... fingerprint={k.get('fingerprint','?')} name={k.get('key_comment','?')}")
try:
choice = int(input("Enter number: ").strip()) - 1
if 0 <= choice < len(verified_keys):
return verified_keys[choice]['id']
except (ValueError, EOFError):
pass
logger.info("Multiple verified SSH keys found; using the most recently added one.")
verified_keys.sort(key=lambda k: k.get('created_at', ''), reverse=True)
return verified_keys[0]['id']
else:
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=auth_headers())
if response.status_code != 200:
logger.error(f"Failed to retrieve SSH keys: {response.status_code} - {response.text}")
exit(1)
keys = response.json().get('data', {}).get('keys', [])
verified_keys = [k for k in keys if k['verified']]
if not verified_keys:
logger.error("No verified SSH keys found for the user.")
exit(1)
if len(verified_keys) > 1 and sys.stdout.isatty():
print("\nMultiple verified SSH keys found. Please choose one:")
for i, k in enumerate(verified_keys):
print(f" [{i+1}] {k['id'][:8]}... fingerprint={k.get('fingerprint','?')} name={k.get('key_comment','?')}")
try:
choice = int(input("Enter number: ").strip()) - 1
if 0 <= choice < len(verified_keys):
return verified_keys[choice]['id']
except (ValueError, EOFError):
pass
logger.info("Invalid choice; using the most recently added key.")
verified_keys.sort(key=lambda k: k.get('created_at', ''), reverse=True)
return verified_keys[0]['id']
except SystemExit:
raise
except Exception as e:
logger.error(f"Error while retrieving SSH keys: {e}")
exit(1)
def request_certificate(principals=None):
def fetch_my_principals():
"""Fetch all principal names the current user is entitled to from the API.
For regular members: returns their assigned principals.
For org admins/owners: returns all principals in the org (they can sign for any).
"""
global token
response = requests.get(
f"{SIGN_URL}/api/v1/users/me/principals",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if response.status_code != 200:
logger.error(f"Failed to fetch principals from server: {response.status_code} - {response.text}")
exit(1)
orgs = response.json().get("data", {}).get("orgs", [])
principal_names = []
for org in orgs:
# Admins/owners get all principals; regular members get only their assigned ones
if org.get("is_admin"):
source = org.get("all_principals", [])
else:
source = org.get("my_principals", [])
for p in source:
if p["name"] not in principal_names:
principal_names.append(p["name"])
return principal_names
def request_certificate():
CERT_ID = os.getenv("CERT_ID") or get_activated_ssh_key()
principals = fetch_my_principals()
if not principals:
env_principals = os.getenv("PRINCIPALS")
if env_principals:
principals = [p.strip() for p in env_principals.split(',')]
else:
principals = [os.getlogin()]
logger.error("You have no principals assigned. Contact your org admin.")
exit(1)
logger.info(f"Requesting certificate for principals: {', '.join(principals)}")
headers = {
'content-type': 'application/json',
@@ -243,7 +277,7 @@ def request_certificate(principals=None):
response = requests.post(f"{SIGN_URL}/api/v1/ssh/sign", json=payload, headers=headers)
if response.status_code == 201:
json_result = response.json()
json_result = response.json().get('data', response.json())
with open(CERT_FILE_PATH, 'w') as f:
f.write(json_result['certificate'])
logger.info(f"Certificate signed successfully, located at {CERT_FILE_PATH}")
@@ -257,124 +291,84 @@ def request_certificate(principals=None):
except Exception as e:
logger.error(f"Error during certificate signing: {e}")
def generate_and_sign_challenge(ssh_key_file,key_id):
"""Generate a challenge text, sign it using the SSH key, and return the signature."""
def generate_and_sign_challenge(ssh_key_file, key_id):
"""Fetch a challenge from the server, sign it with the SSH key, and submit the signature."""
logger.debug(f"generate_and_sign_challenge - {ssh_key_file} {key_id}")
#Fetch challenge text from API
# Fetch challenge text
try:
global token
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
"Content-Type": "application/json",
}
# Send the POST request
response = requests.get(
f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify",
headers=headers
)
if response.status_code!=200:
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify", headers=auth_headers())
if response.status_code != 200:
logger.error(f"Server returned unexpected code {response.status_code}")
return False
challenge_text=response.json().get('challenge_text', response.json().get('validationText', ''))+"\n"
resp_json = response.json()
data = resp_json.get('data', resp_json)
challenge_text = data.get('challenge_text', data.get('validationText', '')) + "\n"
except Exception as e:
logger.error(f"Unable to fetch SSH Key validation data {e}")
logger.error(f"Unable to fetch SSH Key validation data: {e}")
return False
# Sign the challenge
try:
logger.debug(f"generate_and_sign_challenge - procesing challenge with text {challenge_text}")
if os.path.exists(CHALLENGE_FILE_PATH):
os.remove(CHALLENGE_FILE_PATH)
if os.path.exists(CHALLENGE_SIG_FILE_PATH):
os.remove(CHALLENGE_SIG_FILE_PATH)
for path in (CHALLENGE_FILE_PATH, CHALLENGE_SIG_FILE_PATH):
if os.path.exists(path):
os.remove(path)
with open(CHALLENGE_FILE_PATH, 'w') as f:
f.write(challenge_text)
# Sign the challenge text using the SSH key
result=subprocess.run(["ssh-keygen", "-Y", "sign", "-f", ssh_key_file, "-n", "file", CHALLENGE_FILE_PATH], check=True)
logger.debug(f"generate_and_sign_challenge - {result}")
# Read the signature
with open(CHALLENGE_SIG_FILE_PATH, 'rb') as f:
signature = base64.b64encode(f.read()).decode('utf-8')
submit_signature_validation(signature,key_id)
return signature
except Exception as e:
logger.error(f"Unable to sign the challenge reponse {e}")
def submit_signature_validation(signature, key_id):
try:
# Define the headers and payload
global token
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
"Content-Type": "application/json",
}
logger.debug(f"submit_signature_validation - {signature}")
payload = {
"signature": signature
}
# Send the POST request
response = requests.post(
f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify",
headers=headers,
json=payload
subprocess.run(
["ssh-keygen", "-Y", "sign", "-f", ssh_key_file, "-n", "file", CHALLENGE_FILE_PATH],
check=True,
)
# Print the response
print(response.status_code)
print(response.text)
with open(CHALLENGE_SIG_FILE_PATH, 'rb') as f:
signature = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.error(f"submit_signature_validation - Unable to submit the challenge response {e}")
logger.error(f"Unable to sign the challenge response: {e}")
return False
# Submit signature
try:
response = requests.post(
f"{SIGN_URL}/api/v1/ssh/keys/{key_id}/verify",
headers=auth_headers(),
json={"signature": signature},
)
if response.status_code == 200:
logger.info("SSH key verified successfully.")
else:
logger.error(f"Verification failed: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Unable to submit the challenge response: {e}")
return signature
def remove_ssh_key(key_id=None):
"""
Remove an SSH key from the server. If key_id is None, list keys and prompt user to pick one.
"""
global token
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
# List keys first
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=headers)
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=auth_headers())
if response.status_code != 200:
logger.error(f"Failed to list SSH keys: {response.status_code} - {response.text}")
exit(1)
keys = response.json().get('keys', [])
keys = response.json().get('data', {}).get('keys', [])
if not keys:
logger.info("No SSH keys found for your user.")
return
if key_id:
# Delete specific key
target = next((k for k in keys if k['id'] == key_id), None)
if not target:
logger.error(f"Key ID {key_id} not found in your profile.")
exit(1)
keys_to_delete = [target]
else:
# Show all keys and let user pick
print("\nYour SSH keys:")
for i, k in enumerate(keys):
verified = "✓ verified" if k['verified'] else "✗ unverified"
print(f" [{i+1}] {k['id']} {verified} {k.get('description','')} (added {k['created_at'][:10]})")
print(f" [{i+1}] {k['id']} {verified} {k.get('description', '')} (added {k['created_at'][:10]})")
print(" [a] Delete ALL keys")
print(" [q] Quit")
choice = input("\nEnter number to delete (or 'a' for all, 'q' to quit): ").strip().lower()
@@ -394,7 +388,7 @@ def remove_ssh_key(key_id=None):
exit(1)
for k in keys_to_delete:
del_response = requests.delete(f"{SIGN_URL}/api/v1/ssh/keys/{k['id']}", headers=headers)
del_response = requests.delete(f"{SIGN_URL}/api/v1/ssh/keys/{k['id']}", headers=auth_headers())
if del_response.status_code == 200:
logger.info(f"Key {k['id']} removed successfully.")
else:
@@ -402,56 +396,36 @@ def remove_ssh_key(key_id=None):
def add_ssh_key(ssh_key_file):
"""
Add an SSH key to the server.
"""Add an SSH key to the server and auto-verify it."""
if hasattr(ssh_key_file, 'read'):
key_bytes = ssh_key_file.read()
key_path = ssh_key_file.name
elif isinstance(ssh_key_file, bytes):
key_bytes = ssh_key_file
key_path = None
else:
key_path = str(ssh_key_file)
with open(key_path, 'rb') as f:
key_bytes = f.read()
Args:
ssh_key_file (file): The SSH key file to be added.
"""
global token
ssh_key = key_bytes.decode('utf-8').strip()
payload = {
'description': 'Added via gatehouse CLI tool',
'key': ssh_key,
}
if not token:
raise EnvironmentError("TOKEN environment variable is not set")
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
if hasattr(ssh_key_file, 'read'):
# File object (e.g. argparse.FileType('rb'))
key_bytes = ssh_key_file.read()
key_path = ssh_key_file.name
elif isinstance(ssh_key_file, bytes):
key_bytes = ssh_key_file
key_path = None
else:
# String path
key_path = str(ssh_key_file)
with open(key_path, 'rb') as f:
key_bytes = f.read()
ssh_key = key_bytes.decode('utf-8').strip()
payload = {
'description': 'Added via gatehouse CLI tool',
'key': ssh_key
}
response = requests.post(f"{SIGN_URL}/api/v1/ssh/keys", json=payload, headers=headers)
if response.status_code == 201:
ssh_key_id=response.json()['id']
logger.info(f"SSH key {ssh_key_id} added successfully")
if key_path:
# Strip .pub suffix to get the private key path for signing
private_key_path = key_path[:-4] if key_path.endswith('.pub') else key_path
generate_and_sign_challenge(private_key_path, ssh_key_id)
else:
logger.warning("No key file path available — skipping auto-verification. "
"Run with -k <path> to enable automatic key verification.")
else:
logger.error(f"Failed to add SSH key: {response.status_code} - {response.text}")
response = requests.post(f"{SIGN_URL}/api/v1/ssh/keys", json=payload, headers=auth_headers())
if response.status_code == 201:
ssh_key_id = response.json().get('data', {}).get('id')
logger.info(f"SSH key {ssh_key_id} added successfully")
if key_path:
private_key_path = key_path[:-4] if key_path.endswith('.pub') else key_path
generate_and_sign_challenge(private_key_path, ssh_key_id)
else:
logger.warning("No key file path available — skipping auto-verification. "
"Run with -k <path> to enable automatic key verification.")
else:
logger.error(f"Failed to add SSH key: {response.status_code} - {response.text}")
def checkCert():
logger.info("Running cert check")
@@ -459,8 +433,11 @@ def checkCert():
logger.warning("Certificate does not exist, new certificate required")
return 1
# Check the current cert first
certificate = SSHCertificate.from_file(CERT_FILE_PATH)
try:
certificate = SSHCertificate.from_file(CERT_FILE_PATH)
except Exception:
logger.warning("Certificate file is invalid or corrupt, renewal required")
return 1
# Get the current datetime
now = datetime.datetime.now()
@@ -486,7 +463,6 @@ if __name__ == "__main__":
parser.add_argument("-a", "--add-key", action='store_true', default=False, help="Add SSH key to the server")
parser.add_argument("-c", "--check-cert", action='store_true', default=False, help="Check the certificate, if it's valid exit 0, if it's invalid exit 1")
parser.add_argument("-r", "--request-cert", action='store_true', default=False, help="Request that gatehouse sign a new certificate for you based on an SSH public key on file in your profile")
parser.add_argument("--principals", nargs='+', metavar='PRINCIPAL', help="Unix usernames for the certificate (default: current OS user)")
parser.add_argument("--clear-cache", action='store_true', default=False, help="Remove the cached authentication token")
parser.add_argument("--remove-key", nargs='?', const='', metavar='KEY_ID', help="Remove an SSH key from your profile. Omit KEY_ID to pick interactively.")
parser.add_argument("--list-keys", action='store_true', default=False, help="List SSH keys in your profile")
@@ -515,13 +491,9 @@ if __name__ == "__main__":
if args.list_keys:
request_token()
response = requests.get(
f"{SIGN_URL}/api/v1/ssh/keys",
headers={"Authorization": f"Bearer {token}"},
)
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=auth_headers())
if response.status_code == 200:
data = response.json()
keys = data.get('keys', [])
keys = response.json().get('data', {}).get('keys', [])
if not keys:
print("No SSH keys found in your profile.")
else:
@@ -549,13 +521,9 @@ if __name__ == "__main__":
if args.request_cert:
request_token()
if args.force:
request_token()
logger.info("Forcing renewal of certificate")
request_certificate(principals=args.principals)
if checkCert() == 1:
request_token()
request_certificate(principals=args.principals)
if args.force or checkCert() == 1:
request_certificate()
exit(0)
+8
View File
@@ -116,3 +116,11 @@ class BaseConfig:
# Frontend URL (for OAuth callback redirects)
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:8080")
# Email / SMTP
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true"
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local")
+12
View File
@@ -25,3 +25,15 @@ class DevelopmentConfig(BaseConfig):
"CORS_ORIGINS",
"http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local"
).split(",")
# ── Email / SMTP ──────────────────────────────────────────────────────────
# Read from .env so real SMTP credentials work in dev.
# Set EMAIL_ENABLED=false in .env to disable; defaults to True if SMTP_HOST is set.
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "True").lower() == "true"
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
SMTP_PORT = int(os.getenv("SMTP_PORT", "1025"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME") or None
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") or None
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "").lower() == "true" if os.getenv("SMTP_USE_TLS") else int(os.getenv("SMTP_PORT", "1025")) not in (25, 1025)
FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local")
EMAIL_FROM = FROM_ADDRESS # alias
+8 -92
View File
@@ -1,114 +1,30 @@
[default]
# Certificate validity period (in hours)
# Default: 1 hour
cert_validity_hours=1
cert_validity_hours=8
# Maximum certificate validity allowed (in hours)
# Default: 24 hours
# Prevents users from requesting certificates valid longer than this
max_cert_validity_hours=24
max_cert_validity_hours=720
# Certificate Request Limits
# Maximum number of certificates per user
max_certs_per_user=100
# Certificate revocation list (CRL) configuration
crl_enabled=true
# CRL endpoint URL - set to your domain where CRL is served
crl_endpoint=https://ca.example.com/crl
# CRL refresh interval (in hours)
crl_refresh_hours=24
# CA Key Configuration
# Default key type for new CAs (ed25519, rsa, ecdsa)
default_key_type=ed25519
# RSA key size (if using RSA)
rsa_key_bits=4096
# Private key encryption
# Method: kms (AWS Key Management Service) or local (for development only)
private_key_encryption=kms
# AWS KMS Key ID (only used if private_key_encryption=kms)
aws_kms_key_id=${SSH_CA_KMS_KEY_ID}
# SSH Certificate Extensions
# Default extensions to add to certificates
extensions_enabled=true
extensions=permit-X11-forwarding,permit-agent-forwarding,permit-pty,permit-port-forwarding,permit-user-rc
# Critical Options
# Critical options to add to certificates (rarely needed)
critical_options_enabled=false
# CA private key path (required for local encryption mode)
ca_key_path=
# Certificate Field Limits
# Maximum number of principals per certificate (SSH limitation is 256)
max_principals_per_cert=256
# Maximum length for key_id field
max_key_id_length=255
# Logging Configuration
# Log level for SSH CA operations (DEBUG, INFO, WARNING, ERROR)
log_level=INFO
# Audit Configuration
# Log all certificate signing operations
audit_enabled=true
# Security Configuration
# Require SSH key verification before issuing certificates
require_key_verification=true
# Verification challenge max age (in hours)
verification_challenge_max_age=24
# Rate limiting for certificate signing
# Max certificates per minute per user
rate_limit_certs_per_minute=5
# Request timeout (in seconds)
request_timeout=30
# Cleanup Configuration
# Automatically delete unverified SSH keys after this many days
# Cleanup: delete unverified SSH keys after this many days
auto_delete_unverified_days=30
# Archive expired certificates after this many days
archive_expired_days=365
# CLI OAuth Configuration (for secuird-cli.py compatibility)
# OAuth token endpoint for CLI clients
oauth_token_endpoint=/api/v1/oauth2/token
# OAuth userinfo endpoint for CLI clients
oauth_userinfo_endpoint=/api/v1/oauth2/userinfo
[development]
# Override settings for development environment
private_key_encryption=local
ca_key_path=/home/james/cory/secuird/certs/ca-users
log_level=DEBUG
ca_key_path=${SSH_CA_KEY_PATH}
cert_validity_hours=24
max_cert_validity_hours=720
rate_limit_certs_per_minute=100
require_key_verification=false
[production]
# Override settings for production environment
private_key_encryption=kms
log_level=WARNING
cert_validity_hours=1
max_cert_validity_hours=24
rate_limit_certs_per_minute=5
require_key_verification=true
cert_validity_hours=8
[testing]
# Override settings for testing environment
private_key_encryption=local
log_level=DEBUG
cert_validity_hours=1
max_cert_validity_hours=24
rate_limit_certs_per_minute=100
require_key_verification=true
audit_enabled=false
cert_validity_hours=8
+2 -2
View File
@@ -21,7 +21,7 @@ from gatehouse_app.extensions import db
from gatehouse_app.extensions import bcrypt as flask_bcrypt
from gatehouse_app.extensions import redis_client as _redis_client_ref # may be None until app init
from gatehouse_app.models import User, OIDCClient
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
# ---------------------------------------------------------------------------
@@ -326,7 +326,7 @@ def oidc_complete():
400: invalid request
401: invalid token
"""
from gatehouse_app.models.session import Session as GHSession
from gatehouse_app.models.user.session import Session as GHSession
from gatehouse_app.utils.constants import SessionStatus
data = request.get_json(silent=True) or {}
+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",
)
+8 -45
View File
@@ -20,7 +20,7 @@ class SSHCAConfig:
Example:
config = SSHCAConfig()
cert_hours = config.get_int('cert_validity_hours')
kms_key = config.get_str('aws_kms_key_id')
key_path = config.get_str('ca_key_path')
"""
# Configuration file location (relative to project root)
@@ -28,32 +28,13 @@ class SSHCAConfig:
# Default values if config file is missing
DEFAULTS = {
'cert_validity_hours': '1',
'max_cert_validity_hours': '24',
'max_certs_per_user': '100',
'crl_enabled': 'true',
'crl_endpoint': 'https://ca.example.com/crl',
'crl_refresh_hours': '24',
'default_key_type': 'ed25519',
'rsa_key_bits': '4096',
'private_key_encryption': 'kms',
'aws_kms_key_id': '',
'extensions_enabled': 'true',
'extensions': 'permit-X11-forwarding,permit-agent-forwarding,permit-pty,permit-port-forwarding,permit-user-rc',
'critical_options_enabled': 'false',
'cert_validity_hours': '8',
'max_cert_validity_hours': '720',
'ca_key_path': '',
'max_principals_per_cert': '256',
'max_key_id_length': '255',
'log_level': 'INFO',
'audit_enabled': 'true',
'require_key_verification': 'true',
'verification_challenge_max_age': '24',
'rate_limit_certs_per_minute': '5',
'request_timeout': '30',
'auto_delete_unverified_days': '30',
'archive_expired_days': '365',
'oauth_token_endpoint': '/api/v1/oauth2/token',
'oauth_userinfo_endpoint': '/api/v1/oauth2/userinfo',
'ca_key_path': '',
}
def __init__(self, config_file: Optional[str] = None, environment: Optional[str] = None):
@@ -206,33 +187,15 @@ class SSHCAConfig:
except ValueError as e:
errors.append(f"Invalid cert validity hours: {e}")
# Check key type
valid_key_types = ['ed25519', 'rsa', 'ecdsa']
key_type = self.get_str('default_key_type', 'ed25519')
if key_type not in valid_key_types:
errors.append(f"Invalid key type: {key_type}. Must be one of {valid_key_types}")
# Check encryption method
valid_methods = ['kms', 'local']
encryption = self.get_str('private_key_encryption', 'kms')
if encryption not in valid_methods:
errors.append(f"Invalid private_key_encryption: {encryption}. Must be one of {valid_methods}")
# Warn if using local encryption in production
if encryption == 'local' and self.environment == 'production':
errors.append("WARNING: Using local key encryption in production! Use KMS instead.")
# Check KMS key ID if using KMS
if encryption == 'kms':
kms_key = self.get_str('aws_kms_key_id', '').strip()
if not kms_key:
errors.append("aws_kms_key_id not set but private_key_encryption=kms")
# Check principals limit
max_principals = self.get_int('max_principals_per_cert')
if max_principals > 256:
errors.append(f"max_principals_per_cert ({max_principals}) exceeds SSH limit of 256")
# Check ca_key_path is set
if not self.get_str('ca_key_path', '').strip():
errors.append("ca_key_path is not set")
return errors
def to_dict(self) -> dict:
+12 -2
View File
@@ -1,5 +1,6 @@
"""Security headers middleware."""
from flask import request
import os
from flask import current_app, request
class SecurityHeadersMiddleware:
@@ -34,13 +35,22 @@ class SecurityHeadersMiddleware:
)
# Content Security Policy
try:
flask_env = current_app.config.get("ENV") or os.environ.get("FLASK_ENV", "production")
if flask_env == "development":
connect_src = "connect-src 'self' http://localhost:5000 http://127.0.0.1:5000"
else:
connect_src = "connect-src 'self'"
except RuntimeError:
connect_src = "connect-src 'self'"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'"
+ connect_src
)
# Referrer Policy
+2 -2
View File
@@ -25,7 +25,7 @@ class LoginSchema(Schema):
email = fields.Email(required=True)
password = fields.Str(required=True, validate=validate.Length(min=1))
remember_me = fields.Bool(missing=False)
remember_me = fields.Bool(load_default=False)
class RefreshTokenSchema(Schema):
@@ -78,7 +78,7 @@ class TOTPVerifySchema(Schema):
"""Schema for TOTP code verification during login."""
code = fields.Str(required=True)
is_backup_code = fields.Bool(missing=False)
is_backup_code = fields.Bool(load_default=False)
client_timestamp = fields.Int(
required=False,
allow_none=True,
+1 -1
View File
@@ -1,6 +1,6 @@
"""Audit service."""
from flask import request, g
from gatehouse_app.models.audit_log import AuditLog
from gatehouse_app.models.auth.audit_log import AuditLog
from gatehouse_app.utils.constants import AuditAction
+3 -3
View File
@@ -5,9 +5,9 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from flask import request, g, current_app
from gatehouse_app.extensions import db, bcrypt
from gatehouse_app.models.user import User
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.session import Session
from gatehouse_app.models.user.user import User
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
@@ -8,7 +8,7 @@ from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.authentication_method import (
from gatehouse_app.models.auth.authentication_method import (
OAuthState,
ApplicationProviderConfig,
OrganizationProviderOverride
@@ -1210,12 +1210,35 @@ class ExternalAuthService:
else:
email_verified = data.get("email_verified", False)
sub = data.get("sub")
# Derive email from sub when the provider omits the email claim.
# This happens with some OIDC servers (including the nav-security mock)
# that only return the minimal {sub, iss, iat, exp} set.
# Rule: if sub looks like an email address, use it directly.
# Otherwise, construct a deterministic fallback so we never get NULL.
raw_email = data.get("email")
if not raw_email and sub:
import re as _re
if _re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", sub):
raw_email = sub
email_verified = True # if sub IS the email it's already verified
else:
# e.g. "12345" → "12345@google.local" so we can store it
raw_email = f"{sub}@{provider or 'oauth'}.local"
email_verified = False
# Derive display name when omitted
raw_name = data.get("name") or data.get("display_name")
if not raw_name and raw_email:
raw_name = raw_email.split("@")[0]
# Standardize user info
return {
"provider_user_id": data.get("sub"),
"email": data.get("email"),
"provider_user_id": sub,
"email": raw_email,
"email_verified": email_verified,
"name": data.get("name"),
"name": raw_name,
"first_name": data.get("given_name"),
"last_name": data.get("family_name"),
"picture": data.get("picture"),
+6 -6
View File
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from gatehouse_app.extensions import db
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user_security_policy import UserSecurityPolicy
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.user import User
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.security.user_security_policy import UserSecurityPolicy
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.user.user import User
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import (
MfaPolicyMode,
@@ -702,7 +702,7 @@ class MfaPolicyService:
if now is None:
now = datetime.now(timezone.utc)
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
updated_count = 0
+55 -94
View File
@@ -19,9 +19,9 @@ import logging
import json
from gatehouse_app.extensions import db
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user import User
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user.user import User
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import AuditAction
@@ -37,6 +37,7 @@ class NotificationService:
SMTP_PORT_KEY = "SMTP_PORT"
SMTP_USERNAME_KEY = "SMTP_USERNAME"
SMTP_PASSWORD_KEY = "SMTP_PASSWORD"
SMTP_USE_TLS_KEY = "SMTP_USE_TLS"
FROM_ADDRESS_KEY = "FROM_ADDRESS"
@staticmethod
@@ -86,10 +87,9 @@ class NotificationService:
if success:
logger.info(
f"Sent MFA deadline reminder to {user.email} "
f"({days_until_deadline} days remaining # Audit log
)"
f"({days_until_deadline} days remaining)"
)
AuditService.log_action(
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
user_id=user.id,
organization_id=compliance.organization_id,
@@ -291,101 +291,62 @@ Gatehouse Security Team
body: str,
html_body: Optional[str] = None,
) -> bool:
"""Send an email notification.
"""Send an email via SMTP.
This method attempts to send an email using configured SMTP settings.
If email is not configured, it logs the notification instead.
Args:
to_address: Recipient email address
subject: Email subject
body: Plain text email body
html_body: Optional HTML email body
Returns:
True if email was sent (or logged), False on error
Returns True if the email was sent successfully, False otherwise.
If EMAIL_ENABLED is False, logs the email body instead (simulation mode).
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import current_app
email_enabled = current_app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
if not email_enabled:
logger.info(
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
f"Body: {body[:500]}"
)
return False
smtp_host = current_app.config.get(NotificationService.SMTP_HOST_KEY, "localhost")
smtp_port = int(current_app.config.get(NotificationService.SMTP_PORT_KEY, 587))
smtp_username = current_app.config.get(NotificationService.SMTP_USERNAME_KEY)
smtp_password = current_app.config.get(NotificationService.SMTP_PASSWORD_KEY)
smtp_use_tls = current_app.config.get(
NotificationService.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
)
from_address = current_app.config.get(
NotificationService.FROM_ADDRESS_KEY, "noreply@gatehouse.local"
)
try:
from flask import current_app
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
msg.attach(MIMEText(body, "plain"))
if html_body:
msg.attach(MIMEText(html_body, "html"))
# Check if email is configured
email_enabled = current_app.config.get(
NotificationService.EMAIL_ENABLED_KEY, False
)
if not email_enabled:
# Log the notification instead of sending
logger.info(
f"[EMAIL SIMULATION] To: {to_address}\n"
f"Subject: {subject}\n"
f"Body: {body[:200]}..." if len(body) > 200 else f"Body: {body}"
)
return True
# Get email configuration
smtp_host = current_app.config.get(NotificationService.SMTP_HOST_KEY)
smtp_port = current_app.config.get(NotificationService.SMTP_PORT_KEY, 587)
smtp_username = current_app.config.get(NotificationService.SMTP_USERNAME_KEY)
smtp_password = current_app.config.get(NotificationService.SMTP_PASSWORD_KEY)
from_address = current_app.config.get(
NotificationService.FROM_ADDRESS_KEY, "noreply@gatehouse.local"
)
# Import send_email based on available mail library
try:
from flask_mail import Message
from gatehouse_app import mail
msg = Message(
subject=subject,
recipients=[to_address],
body=body,
html=html_body,
sender=from_address,
)
mail.send(msg)
logger.info(f"Email sent successfully to {to_address}")
return True
except ImportError:
# Flask-Mail not available, use SMTP directly
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
# Attach plain text and HTML versions
part1 = MIMEText(body, "plain")
msg.attach(part1)
if html_body:
part2 = MIMEText(html_body, "html")
msg.attach(part2)
# Send via SMTP
with smtplib.SMTP(smtp_host, smtp_port) as server:
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
server.ehlo()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
logger.info(f"Email sent successfully to {to_address}")
return True
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
return True
except Exception as e:
logger.exception(f"Failed to send email to {to_address}: {e}")
# Log the notification as fallback
logger.info(
f"[EMAIL FALLBACK] To: {to_address}\n"
f"Subject: {subject}\n"
f"Body: {body[:500]}..." if len(body) > 500 else f"Body: {body}"
)
return True # Return True to continue processing
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
return False
@staticmethod
def get_notification_stats(user_id: str) -> Dict[str, Any]:
@@ -397,7 +358,7 @@ Gatehouse Security Team
Returns:
Dictionary with notification statistics
"""
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
stats = {
"total_notifications": 0,
+5 -5
View File
@@ -9,9 +9,9 @@ from flask import current_app, request, g, redirect
from gatehouse_app.extensions import db
from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.authentication_method import OAuthState
from gatehouse_app.models.auth.authentication_method import OAuthState
from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.oidc_authorization_code import OIDCAuthCode
from gatehouse_app.models.oidc.oidc_authorization_code import OIDCAuthCode
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.external_auth_service import (
@@ -776,7 +776,7 @@ class OAuthFlowService:
# If organization_id hint was provided and valid, create session for that org
if state_record.organization_id:
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization import Organization
org = Organization.query.get(state_record.organization_id)
if org:
from gatehouse_app.services.auth_service import AuthService
@@ -987,8 +987,8 @@ class OAuthFlowService:
)
# Determine organization
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
# Get user's organizations
user_orgs = user.get_organizations()
+1 -1
View File
@@ -8,7 +8,7 @@ from typing import Dict, List, Optional, Tuple
from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models.oidc_jwks_key import OidcJwksKey
from gatehouse_app.models.oidc.oidc_jwks_key import OidcJwksKey
class JWKSKey:
+1 -1
View File
@@ -14,7 +14,7 @@ from gatehouse_app.models import (
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
OIDCSession, OIDCTokenMetadata
)
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.exceptions.validation_exceptions import (
ValidationError, NotFoundError, BadRequestError
)
+1 -1
View File
@@ -11,7 +11,7 @@ import jwt
from flask import current_app, g
from gatehouse_app.models import User, OIDCClient
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
logger = logging.getLogger(__name__)
@@ -3,8 +3,8 @@ import logging
from datetime import datetime, timezone
from flask import current_app
from gatehouse_app.extensions import db
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
from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
from gatehouse_app.utils.constants import OrganizationRole, AuditAction
from gatehouse_app.services.audit_service import AuditService
+2 -2
View File
@@ -1,6 +1,6 @@
"""Session service."""
from datetime import datetime, timezone
from gatehouse_app.models.session import Session
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import SessionStatus
@@ -17,7 +17,7 @@ class SessionService:
Returns:
Session object if found and active, None otherwise
"""
from gatehouse_app.models.session import Session
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import SessionStatus
return Session.query.filter_by(
token=token,
@@ -253,12 +253,13 @@ class SSHCASigningService:
certificate.fields.valid_after = now
certificate.fields.valid_before = valid_before
# Set extensions
# Set extensions — prefer policy-provided list, fall back to standard set
extensions = signing_request.extensions
if not extensions and self.config.get_bool('extensions_enabled'):
extensions = self.config.get_list('extensions')
if not extensions:
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
extensions = list(STANDARD_EXTENSIONS)
certificate.fields.extensions = extensions or []
certificate.fields.extensions = extensions
certificate.fields.critical_options = signing_request.critical_options or {}
# Validate certificate before signing
@@ -291,6 +291,8 @@ class SSHKeyService:
logger.info(f"SSH key verified: {key_id}")
return True
except SSHKeyError:
raise
except Exception as e:
logger.error(f"SSH key verification failed: {str(e)}")
raise SSHKeyError(f"Signature verification failed: {str(e)}")
+1 -1
View File
@@ -2,7 +2,7 @@
import logging
from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models.user import User
from gatehouse_app.models.user.user import User
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
+2 -2
View File
@@ -10,8 +10,8 @@ from flask import current_app
from sqlalchemy.orm.attributes import flag_modified
from gatehouse_app.extensions import db, redis_client
from gatehouse_app.models.user import User
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.user.user import User
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.services.audit_service import AuditService
+2
View File
@@ -61,6 +61,8 @@ class AuditAction(str, Enum):
USER_REGISTER = "user.register"
USER_UPDATE = "user.update"
USER_DELETE = "user.delete"
USER_SUSPEND = "user.suspend"
USER_UNSUSPEND = "user.unsuspend"
PASSWORD_CHANGE = "user.password_change"
PASSWORD_RESET = "user.password_reset"
+38 -1
View File
@@ -69,6 +69,42 @@ def login_required(f):
g.current_user = session.user
g.current_session = session
user = session.user
token_groups: list = []
try:
if session.device_info:
# device_info may carry OIDC claims stored at login time
claims = session.device_info
# Normalise: Gatehouse stores roles as [{"organization_id":…,"role":…}]
roles_claim = claims.get("roles", [])
if isinstance(roles_claim, list):
for entry in roles_claim:
if isinstance(entry, dict):
role_val = entry.get("role")
if role_val:
token_groups.append(str(role_val))
elif isinstance(entry, str):
token_groups.append(entry)
# Standard OIDC groups claim
groups_claim = claims.get("groups", [])
if isinstance(groups_claim, list):
token_groups.extend(str(g_) for g_ in groups_claim if g_)
except Exception:
pass # Never block auth over token_groups enrichment failure
user.token_groups = token_groups
# Activation check: if the user has an `activated` attribute and it is
# explicitly False, block access. New accounts without the attribute are
# treated as active to avoid breaking existing sessions.
activated = getattr(user, "activated", None)
if activated is False:
return api_response(
success=False,
message="Account not yet activated. Please check your email for an activation link.",
status=403,
error_type="ACCOUNT_NOT_ACTIVATED",
)
return f(*args, **kwargs)
return decorated_function
@@ -97,11 +133,12 @@ def require_role(*allowed_roles):
raise ForbiddenError("Organization context required")
# Check user's role in the organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
membership = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=org_id,
deleted_at=None,
).first()
if not membership:
+74
View File
@@ -111,5 +111,79 @@ def mfa_compliance_status():
print("=" * 60)
@cli.command("configure_oauth")
def configure_oauth():
"""Interactively configure an OAuth provider at the application level.
Usage:
python manage.py configure_oauth
Supported providers: google, github, microsoft
"""
import getpass
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
from gatehouse_app.extensions import db
SUPPORTED = ["google", "github", "microsoft"]
print("=" * 60)
print("OAuth Provider Configuration")
print("=" * 60)
print(f"Supported providers: {', '.join(SUPPORTED)}")
provider = input("Provider [google/github/microsoft]: ").strip().lower()
if provider not in SUPPORTED:
print(f"❌ Unknown provider: {provider}")
return
client_id = input("Client ID: ").strip()
if not client_id:
print("❌ client_id is required")
return
client_secret = getpass.getpass("Client Secret (leave blank to keep existing): ").strip()
with app.app_context():
config = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
if config:
config.client_id = client_id
if client_secret:
config.set_client_secret(client_secret)
config.is_enabled = True
db.session.commit()
print(f"✅ Updated {provider} provider config.")
else:
config = ApplicationProviderConfig(
provider_type=provider,
client_id=client_id,
is_enabled=True,
)
if client_secret:
config.set_client_secret(client_secret)
db.session.add(config)
db.session.commit()
print(f"✅ Created {provider} provider config.")
@cli.command("list_oauth")
def list_oauth():
"""List all configured OAuth providers.
Usage:
python manage.py list_oauth
"""
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
with app.app_context():
configs = ApplicationProviderConfig.query.all()
if not configs:
print("No OAuth providers configured.")
return
print(f"{'Provider':<15} {'Client ID':<40} {'Enabled'}")
print("-" * 65)
for c in configs:
print(f"{c.provider_type:<15} {c.client_id:<40} {c.is_enabled}")
if __name__ == "__main__":
cli()
@@ -0,0 +1,50 @@
"""add password reset and email verification token tables
Revision ID: 010_password_reset_email_verify
Revises: 009_sync_auditaction_enum
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '010_password_reset_email_verify'
down_revision = '009'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'password_reset_tokens',
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('token', sa.String(128), nullable=False, unique=True),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('used_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.Column('deleted_at', sa.DateTime, nullable=True),
)
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
op.create_table(
'email_verification_tokens',
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('token', sa.String(128), nullable=False, unique=True),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('used_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.Column('deleted_at', sa.DateTime, nullable=True),
)
op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id'])
op.create_index('ix_email_verification_tokens_token', 'email_verification_tokens', ['token'])
def downgrade():
op.drop_table('email_verification_tokens')
op.drop_table('password_reset_tokens')
@@ -0,0 +1,38 @@
"""add org_invite_tokens table
Revision ID: 011_org_invite_tokens
Revises: 010_password_reset_email_verify
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '011_org_invite_tokens'
down_revision = '010_password_reset_email_verify'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'org_invite_tokens',
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
sa.Column('organization_id', sa.String(36), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False),
sa.Column('invited_by_id', sa.String(36), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('role', sa.String(64), nullable=False, server_default='member'),
sa.Column('token', sa.String(128), nullable=False, unique=True),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('accepted_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.Column('deleted_at', sa.DateTime, nullable=True),
)
op.create_index('ix_org_invite_tokens_organization_id', 'org_invite_tokens', ['organization_id'])
op.create_index('ix_org_invite_tokens_email', 'org_invite_tokens', ['email'])
op.create_index('ix_org_invite_tokens_token', 'org_invite_tokens', ['token'])
def downgrade():
op.drop_table('org_invite_tokens')
@@ -0,0 +1,44 @@
"""add_department_cert_policies
Adds the department_cert_policies table which stores per-department
SSH certificate issuance rules:
- whether users may choose their own expiry
- default and maximum expiry durations
- allowed SSH certificate extensions
"""
from alembic import op
import sqlalchemy as sa
revision = "014_add_dept_cert_policy"
down_revision = "013"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"department_cert_policies",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("department_id", sa.String(36), sa.ForeignKey("departments.id"), nullable=False, unique=True),
# Whether users are allowed to specify their own expiry (up to max)
sa.Column("allow_user_expiry", sa.Boolean(), nullable=False, server_default="0"),
# Default validity in hours (used when user doesn't specify, or not allowed to)
sa.Column("default_expiry_hours", sa.Integer(), nullable=False, server_default="1"),
# Hard cap on validity; admin cannot be exceeded
sa.Column("max_expiry_hours", sa.Integer(), nullable=False, server_default="24"),
# JSON list of extension names that are enabled for this department
# e.g. ["permit-pty", "permit-agent-forwarding"]
sa.Column("allowed_extensions", sa.JSON(), nullable=False, server_default='["permit-pty","permit-agent-forwarding","permit-X11-forwarding","permit-port-forwarding","permit-user-rc"]'),
# Admin-defined custom extension names beyond the standard five
sa.Column("custom_extensions", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
)
op.create_index("idx_dept_cert_policy_dept", "department_cert_policies", ["department_id"])
def downgrade():
op.drop_index("idx_dept_cert_policy_dept", "department_cert_policies")
op.drop_table("department_cert_policies")
+5 -5
View File
@@ -15,11 +15,11 @@ load_dotenv()
from gatehouse_app import create_app
from gatehouse_app.extensions import db
from gatehouse_app.models.user import User
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.oidc_client import OIDCClient
from gatehouse_app.models.user.user import User
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
from gatehouse_app.models.oidc.oidc_client import OIDCClient
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.utils.constants import OrganizationRole, UserStatus, AuthMethodType