From a0d4e59c244e294cc66dbcd18d35f58fcd03b2b9 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Sun, 1 Mar 2026 16:50:27 +0545 Subject: [PATCH] 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 --- .env.example | 8 +- client/gatehouse-cli.py | 406 ++++++++--------- config/base.py | 8 + config/development.py | 12 + etc/ssh_ca.conf | 100 +---- gatehouse_app/api/oidc.py | 4 +- gatehouse_app/api/v1/auth.py | 413 +++++++++++++++++- gatehouse_app/api/v1/departments.py | 151 ++++++- gatehouse_app/api/v1/external_auth.py | 310 ++++++++++++- gatehouse_app/api/v1/organizations.py | 142 +++++- gatehouse_app/api/v1/policies.py | 46 +- gatehouse_app/api/v1/principals.py | 35 +- gatehouse_app/api/v1/ssh.py | 137 +++++- gatehouse_app/api/v1/users.py | 291 +++++++++++- gatehouse_app/config/ssh_ca_config.py | 61 +-- gatehouse_app/middleware/security_headers.py | 14 +- gatehouse_app/schemas/auth_schema.py | 4 +- gatehouse_app/services/audit_service.py | 2 +- gatehouse_app/services/auth_service.py | 6 +- .../services/external_auth_service.py | 31 +- gatehouse_app/services/mfa_policy_service.py | 12 +- .../services/notification_service.py | 149 +++---- gatehouse_app/services/oauth_flow_service.py | 10 +- gatehouse_app/services/oidc_jwks_service.py | 2 +- gatehouse_app/services/oidc_service.py | 2 +- gatehouse_app/services/oidc_token_service.py | 2 +- .../services/organization_service.py | 4 +- gatehouse_app/services/session_service.py | 4 +- .../services/ssh_ca_signing_service.py | 11 +- gatehouse_app/services/ssh_key_service.py | 2 + gatehouse_app/services/user_service.py | 2 +- gatehouse_app/services/webauthn_service.py | 4 +- gatehouse_app/utils/constants.py | 2 + gatehouse_app/utils/decorators.py | 43 +- manage.py | 74 ++++ .../010_password_reset_email_verify.py | 50 +++ migrations/versions/011_org_invite_tokens.py | 38 ++ .../versions/014_add_dept_cert_policy.py | 44 ++ scripts/seed_data.py | 10 +- 39 files changed, 2035 insertions(+), 611 deletions(-) create mode 100644 migrations/versions/010_password_reset_email_verify.py create mode 100644 migrations/versions/011_org_invite_tokens.py create mode 100644 migrations/versions/014_add_dept_cert_policy.py diff --git a/.env.example b/.env.example index d87fa74..9e97462 100644 --- a/.env.example +++ b/.env.example @@ -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= + diff --git a/client/gatehouse-cli.py b/client/gatehouse-cli.py index b45ce1a..e4f223a 100755 --- a/client/gatehouse-cli.py +++ b/client/gatehouse-cli.py @@ -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() @@ -49,12 +54,13 @@ class MyServer(BaseHTTPRequestHandler): self.wfile.write(bytes("

Window closing in 5 seconds...

", "utf-8")) self.wfile.write(bytes("", "utf-8")) self.wfile.write(bytes("", "utf-8")) - + 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 - 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: + # Fetch challenge text + try: + 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. - - Args: - ssh_key_file (file): The SSH key file to be added. - """ - global token - - if not token: - raise EnvironmentError("TOKEN environment variable is not set") - - headers = { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } + """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() - 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, + } - 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 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 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: @@ -548,14 +520,10 @@ if __name__ == "__main__": exit(0) - if args.request_cert: + 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) diff --git a/config/base.py b/config/base.py index 4a2dfe6..599a9cd 100644 --- a/config/base.py +++ b/config/base.py @@ -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") diff --git a/config/development.py b/config/development.py index 840a84b..5436adf 100644 --- a/config/development.py +++ b/config/development.py @@ -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 diff --git a/etc/ssh_ca.conf b/etc/ssh_ca.conf index babe4dc..212de10 100644 --- a/etc/ssh_ca.conf +++ b/etc/ssh_ca.conf @@ -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 diff --git a/gatehouse_app/api/oidc.py b/gatehouse_app/api/oidc.py index 5ec7700..3821e57 100644 --- a/gatehouse_app/api/oidc.py +++ b/gatehouse_app/api/oidc.py @@ -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 {} diff --git a/gatehouse_app/api/v1/auth.py b/gatehouse_app/api/v1/auth.py index ebffe8d..712d07f 100644 --- a/gatehouse_app/api/v1/auth.py +++ b/gatehouse_app/api/v1/auth.py @@ -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: ``?token=`` + + Returns: + 200: JSON ``{"token": ""}`` (no redirect given) + 302: Redirect to ``?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") diff --git a/gatehouse_app/api/v1/departments.py b/gatehouse_app/api/v1/departments.py index 73ea83d..d305c66 100644 --- a/gatehouse_app/api/v1/departments.py +++ b/gatehouse_app/api/v1/departments.py @@ -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//departments//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//departments//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") + diff --git a/gatehouse_app/api/v1/external_auth.py b/gatehouse_app/api/v1/external_auth.py index d68fd77..846b58d 100644 --- a/gatehouse_app/api/v1/external_auth.py +++ b/gatehouse_app/api/v1/external_auth.py @@ -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= + 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= + 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/", 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/", 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", + ) diff --git a/gatehouse_app/api/v1/organizations.py b/gatehouse_app/api/v1/organizations.py index 8fdc56c..69739bc 100644 --- a/gatehouse_app/api/v1/organizations.py +++ b/gatehouse_app/api/v1/organizations.py @@ -381,6 +381,7 @@ def update_member_role(org_id, user_id): @api_v1_bp.route("/organizations//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//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//invites/", 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/", 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//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 diff --git a/gatehouse_app/api/v1/policies.py b/gatehouse_app/api/v1/policies.py index ddf16f2..c97465f 100644 --- a/gatehouse_app/api/v1/policies.py +++ b/gatehouse_app/api/v1/policies.py @@ -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", ) \ No newline at end of file diff --git a/gatehouse_app/api/v1/principals.py b/gatehouse_app/api/v1/principals.py index 587d81d..0da8315 100644 --- a/gatehouse_app/api/v1/principals.py +++ b/gatehouse_app/api/v1/principals.py @@ -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, ) diff --git a/gatehouse_app/api/v1/ssh.py b/gatehouse_app/api/v1/ssh.py index c301dad..3f89fef 100644 --- a/gatehouse_app/api/v1/ssh.py +++ b/gatehouse_app/api/v1/ssh.py @@ -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') diff --git a/gatehouse_app/api/v1/users.py b/gatehouse_app/api/v1/users.py index 7d318f0..e6ccfb6 100644 --- a/gatehouse_app/api/v1/users.py +++ b/gatehouse_app/api/v1/users.py @@ -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/", 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//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//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", + ) diff --git a/gatehouse_app/config/ssh_ca_config.py b/gatehouse_app/config/ssh_ca_config.py index e3cbda9..7b62a96 100644 --- a/gatehouse_app/config/ssh_ca_config.py +++ b/gatehouse_app/config/ssh_ca_config.py @@ -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): @@ -189,12 +170,12 @@ class SSHCAConfig: def validate_config(self) -> list: """Validate SSH CA configuration. - + Returns: List of validation error messages (empty if valid) """ errors = [] - + # Check cert validity hours try: validity = self.get_int('cert_validity_hours') @@ -205,34 +186,16 @@ 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: diff --git a/gatehouse_app/middleware/security_headers.py b/gatehouse_app/middleware/security_headers.py index eb1b6a4..ea03dc4 100644 --- a/gatehouse_app/middleware/security_headers.py +++ b/gatehouse_app/middleware/security_headers.py @@ -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 diff --git a/gatehouse_app/schemas/auth_schema.py b/gatehouse_app/schemas/auth_schema.py index b2042f7..69d107b 100644 --- a/gatehouse_app/schemas/auth_schema.py +++ b/gatehouse_app/schemas/auth_schema.py @@ -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, diff --git a/gatehouse_app/services/audit_service.py b/gatehouse_app/services/audit_service.py index 3978aa5..85162fb 100644 --- a/gatehouse_app/services/audit_service.py +++ b/gatehouse_app/services/audit_service.py @@ -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 diff --git a/gatehouse_app/services/auth_service.py b/gatehouse_app/services/auth_service.py index 3df5bf3..4c591c7 100644 --- a/gatehouse_app/services/auth_service.py +++ b/gatehouse_app/services/auth_service.py @@ -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 diff --git a/gatehouse_app/services/external_auth_service.py b/gatehouse_app/services/external_auth_service.py index 89bfbf1..f105db4 100644 --- a/gatehouse_app/services/external_auth_service.py +++ b/gatehouse_app/services/external_auth_service.py @@ -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"), diff --git a/gatehouse_app/services/mfa_policy_service.py b/gatehouse_app/services/mfa_policy_service.py index d553fc1..d7b1316 100644 --- a/gatehouse_app/services/mfa_policy_service.py +++ b/gatehouse_app/services/mfa_policy_service.py @@ -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 diff --git a/gatehouse_app/services/notification_service.py b/gatehouse_app/services/notification_service.py index d3afdca..fc9bbe0 100644 --- a/gatehouse_app/services/notification_service.py +++ b/gatehouse_app/services/notification_service.py @@ -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, diff --git a/gatehouse_app/services/oauth_flow_service.py b/gatehouse_app/services/oauth_flow_service.py index 2484cac..b624cb1 100644 --- a/gatehouse_app/services/oauth_flow_service.py +++ b/gatehouse_app/services/oauth_flow_service.py @@ -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() diff --git a/gatehouse_app/services/oidc_jwks_service.py b/gatehouse_app/services/oidc_jwks_service.py index c8423ef..269dc08 100644 --- a/gatehouse_app/services/oidc_jwks_service.py +++ b/gatehouse_app/services/oidc_jwks_service.py @@ -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: diff --git a/gatehouse_app/services/oidc_service.py b/gatehouse_app/services/oidc_service.py index 6157c21..fc6e317 100644 --- a/gatehouse_app/services/oidc_service.py +++ b/gatehouse_app/services/oidc_service.py @@ -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 ) diff --git a/gatehouse_app/services/oidc_token_service.py b/gatehouse_app/services/oidc_token_service.py index 3c1c929..5605d5f 100644 --- a/gatehouse_app/services/oidc_token_service.py +++ b/gatehouse_app/services/oidc_token_service.py @@ -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__) diff --git a/gatehouse_app/services/organization_service.py b/gatehouse_app/services/organization_service.py index e802b84..9bcccda 100644 --- a/gatehouse_app/services/organization_service.py +++ b/gatehouse_app/services/organization_service.py @@ -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 diff --git a/gatehouse_app/services/session_service.py b/gatehouse_app/services/session_service.py index 7186222..7103285 100644 --- a/gatehouse_app/services/session_service.py +++ b/gatehouse_app/services/session_service.py @@ -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, diff --git a/gatehouse_app/services/ssh_ca_signing_service.py b/gatehouse_app/services/ssh_ca_signing_service.py index 3f33b00..308afc1 100644 --- a/gatehouse_app/services/ssh_ca_signing_service.py +++ b/gatehouse_app/services/ssh_ca_signing_service.py @@ -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') - - certificate.fields.extensions = extensions or [] + if not extensions: + from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS + extensions = list(STANDARD_EXTENSIONS) + + certificate.fields.extensions = extensions certificate.fields.critical_options = signing_request.critical_options or {} # Validate certificate before signing diff --git a/gatehouse_app/services/ssh_key_service.py b/gatehouse_app/services/ssh_key_service.py index 69cf7fa..db07b0b 100644 --- a/gatehouse_app/services/ssh_key_service.py +++ b/gatehouse_app/services/ssh_key_service.py @@ -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)}") diff --git a/gatehouse_app/services/user_service.py b/gatehouse_app/services/user_service.py index 94845d1..b8b5fc9 100644 --- a/gatehouse_app/services/user_service.py +++ b/gatehouse_app/services/user_service.py @@ -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 diff --git a/gatehouse_app/services/webauthn_service.py b/gatehouse_app/services/webauthn_service.py index 79ea50a..cba6204 100644 --- a/gatehouse_app/services/webauthn_service.py +++ b/gatehouse_app/services/webauthn_service.py @@ -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 diff --git a/gatehouse_app/utils/constants.py b/gatehouse_app/utils/constants.py index 000f75d..a3983f3 100644 --- a/gatehouse_app/utils/constants.py +++ b/gatehouse_app/utils/constants.py @@ -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" diff --git a/gatehouse_app/utils/decorators.py b/gatehouse_app/utils/decorators.py index e3b0085..5cbb649 100644 --- a/gatehouse_app/utils/decorators.py +++ b/gatehouse_app/utils/decorators.py @@ -64,11 +64,47 @@ def login_required(f): session.last_activity_at = datetime.now(timezone.utc) from gatehouse_app import db db.session.commit() - + # Set context variables 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: diff --git a/manage.py b/manage.py index 088447e..a89d335 100644 --- a/manage.py +++ b/manage.py @@ -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() diff --git a/migrations/versions/010_password_reset_email_verify.py b/migrations/versions/010_password_reset_email_verify.py new file mode 100644 index 0000000..efb836e --- /dev/null +++ b/migrations/versions/010_password_reset_email_verify.py @@ -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') diff --git a/migrations/versions/011_org_invite_tokens.py b/migrations/versions/011_org_invite_tokens.py new file mode 100644 index 0000000..003da63 --- /dev/null +++ b/migrations/versions/011_org_invite_tokens.py @@ -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') diff --git a/migrations/versions/014_add_dept_cert_policy.py b/migrations/versions/014_add_dept_cert_policy.py new file mode 100644 index 0000000..58b6cb3 --- /dev/null +++ b/migrations/versions/014_add_dept_cert_policy.py @@ -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") diff --git a/scripts/seed_data.py b/scripts/seed_data.py index 97450fa..e8c1d04 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -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