Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy
feat: add password reset and email verification flow feat: add org invite listing, cancellation, and invite link fallback feat: add user suspend/unsuspend with audit logging feat: add department certificate policy (expiry, extensions) feat: enforce dept cert policy on SSH certificate signing feat: wire up OIDC consent and token flow (replace mocks) feat: rework CLI auth bridge to use frontend login flow feat: add admin OAuth provider management (CRUD) chore: refactor model import paths after module reorganisation chore: clean up config, decorators, and dev tooling
This commit is contained in:
+6
-2
@@ -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=
|
||||
|
||||
|
||||
+187
-219
@@ -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("<p>Window closing in <span id='countdown'>5</span> seconds...</p>", "utf-8"))
|
||||
self.wfile.write(bytes("<script>var count = 5; setInterval(function() { count--; document.getElementById('countdown').textContent = count; if (count === 0) window.close(); }, 1000);</script>", "utf-8"))
|
||||
self.wfile.write(bytes("</body></html>", "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 <path> to enable automatic key verification.")
|
||||
else:
|
||||
logger.error(f"Failed to add SSH key: {response.status_code} - {response.text}")
|
||||
response = requests.post(f"{SIGN_URL}/api/v1/ssh/keys", json=payload, headers=auth_headers())
|
||||
if response.status_code == 201:
|
||||
ssh_key_id = response.json().get('data', {}).get('id')
|
||||
logger.info(f"SSH key {ssh_key_id} added successfully")
|
||||
if key_path:
|
||||
private_key_path = key_path[:-4] if key_path.endswith('.pub') else key_path
|
||||
generate_and_sign_challenge(private_key_path, ssh_key_id)
|
||||
else:
|
||||
logger.warning("No key file path available — skipping auto-verification. "
|
||||
"Run with -k <path> to enable automatic key verification.")
|
||||
else:
|
||||
logger.error(f"Failed to add SSH key: {response.status_code} - {response.text}")
|
||||
|
||||
def checkCert():
|
||||
logger.info("Running cert check")
|
||||
@@ -459,8 +433,11 @@ def checkCert():
|
||||
logger.warning("Certificate does not exist, new certificate required")
|
||||
return 1
|
||||
|
||||
# Check the current cert first
|
||||
certificate = SSHCertificate.from_file(CERT_FILE_PATH)
|
||||
try:
|
||||
certificate = SSHCertificate.from_file(CERT_FILE_PATH)
|
||||
except Exception:
|
||||
logger.warning("Certificate file is invalid or corrupt, renewal required")
|
||||
return 1
|
||||
|
||||
# Get the current datetime
|
||||
now = datetime.datetime.now()
|
||||
@@ -486,7 +463,6 @@ if __name__ == "__main__":
|
||||
parser.add_argument("-a", "--add-key", action='store_true', default=False, help="Add SSH key to the server")
|
||||
parser.add_argument("-c", "--check-cert", action='store_true', default=False, help="Check the certificate, if it's valid exit 0, if it's invalid exit 1")
|
||||
parser.add_argument("-r", "--request-cert", action='store_true', default=False, help="Request that gatehouse sign a new certificate for you based on an SSH public key on file in your profile")
|
||||
parser.add_argument("--principals", nargs='+', metavar='PRINCIPAL', help="Unix usernames for the certificate (default: current OS user)")
|
||||
parser.add_argument("--clear-cache", action='store_true', default=False, help="Remove the cached authentication token")
|
||||
parser.add_argument("--remove-key", nargs='?', const='', metavar='KEY_ID', help="Remove an SSH key from your profile. Omit KEY_ID to pick interactively.")
|
||||
parser.add_argument("--list-keys", action='store_true', default=False, help="List SSH keys in your profile")
|
||||
@@ -515,13 +491,9 @@ if __name__ == "__main__":
|
||||
|
||||
if args.list_keys:
|
||||
request_token()
|
||||
response = requests.get(
|
||||
f"{SIGN_URL}/api/v1/ssh/keys",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
response = requests.get(f"{SIGN_URL}/api/v1/ssh/keys", headers=auth_headers())
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
keys = data.get('keys', [])
|
||||
keys = response.json().get('data', {}).get('keys', [])
|
||||
if not keys:
|
||||
print("No SSH keys found in your profile.")
|
||||
else:
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -25,3 +25,15 @@ class DevelopmentConfig(BaseConfig):
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local"
|
||||
).split(",")
|
||||
|
||||
# ── Email / SMTP ──────────────────────────────────────────────────────────
|
||||
# Read from .env so real SMTP credentials work in dev.
|
||||
# Set EMAIL_ENABLED=false in .env to disable; defaults to True if SMTP_HOST is set.
|
||||
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "True").lower() == "true"
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "1025"))
|
||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME") or None
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") or None
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "").lower() == "true" if os.getenv("SMTP_USE_TLS") else int(os.getenv("SMTP_PORT", "1025")) not in (25, 1025)
|
||||
FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local")
|
||||
EMAIL_FROM = FROM_ADDRESS # alias
|
||||
|
||||
+8
-92
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authentication endpoints."""
|
||||
import json
|
||||
from flask import request, session, g, jsonify
|
||||
import logging
|
||||
from flask import request, session, g, jsonify, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
@@ -23,6 +24,7 @@ from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.webauthn_service import WebAuthnService
|
||||
from gatehouse_app.services.user_service import UserService
|
||||
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
@@ -57,6 +59,23 @@ def register():
|
||||
full_name=data.get("full_name"),
|
||||
)
|
||||
|
||||
# Send verification email
|
||||
try:
|
||||
from gatehouse_app.models import EmailVerificationToken
|
||||
verify_token = EmailVerificationToken.generate(user_id=user.id)
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
|
||||
subject = "Verify your Gatehouse email address"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"Welcome to Gatehouse! Please verify your email address by clicking the link below (valid for 24 hours):\n"
|
||||
f"{verify_link}\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
|
||||
|
||||
# Create session
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
@@ -179,7 +198,9 @@ def login():
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
@@ -284,7 +305,7 @@ def revoke_session(session_id):
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
"""
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.session import Session
|
||||
|
||||
# Ensure session belongs to current user
|
||||
user_session = Session.query.filter_by(
|
||||
@@ -424,7 +445,7 @@ def verify_totp():
|
||||
)
|
||||
|
||||
# Get user from database
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return api_response(
|
||||
@@ -475,7 +496,9 @@ def verify_totp():
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
@@ -806,7 +829,7 @@ def begin_webauthn_login():
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Find user by email
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.filter_by(
|
||||
email=data["email"].lower(),
|
||||
deleted_at=None
|
||||
@@ -893,7 +916,7 @@ def complete_webauthn_login():
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Get user from database
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
logger.error(f"WebAuthn login complete - user not found: {user_id}")
|
||||
@@ -962,7 +985,9 @@ def complete_webauthn_login():
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
@@ -1142,3 +1167,381 @@ def get_webauthn_status():
|
||||
},
|
||||
message="WebAuthn status retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
_pw_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/forgot-password", methods=["POST"])
|
||||
def forgot_password():
|
||||
"""Request a password reset email.
|
||||
|
||||
Always returns 200 to avoid leaking account existence.
|
||||
|
||||
Request body:
|
||||
email: User email address
|
||||
|
||||
Returns:
|
||||
200: Password reset email sent (or silently no-op if email not found)
|
||||
"""
|
||||
from gatehouse_app.models import User, PasswordResetToken
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Email is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Always return 200 — don't leak whether the email exists
|
||||
user = User.query.filter_by(email=email, deleted_at=None).first()
|
||||
if user:
|
||||
try:
|
||||
reset_token = PasswordResetToken.generate(user_id=user.id)
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
|
||||
subject = "Reset your Gatehouse password"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"You requested a password reset for your Gatehouse account.\n\n"
|
||||
f"Click the link below to reset your password (valid for 2 hours):\n"
|
||||
f"{reset_link}\n\n"
|
||||
f"If you did not request this, you can safely ignore this email.\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
_pw_logger.info(f"Password reset token generated for user {user.id}")
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error generating password reset token: {exc}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="If an account exists for this email, you will receive a password reset link shortly.",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/reset-password", methods=["POST"])
|
||||
def reset_password():
|
||||
"""Reset a user's password using a reset token.
|
||||
|
||||
Request body:
|
||||
token: Password reset token from email
|
||||
password: New password
|
||||
password_confirm: Password confirmation
|
||||
|
||||
Returns:
|
||||
200: Password reset successfully
|
||||
400: Invalid or expired token / validation error
|
||||
"""
|
||||
import bcrypt as _bcrypt
|
||||
from gatehouse_app.extensions import bcrypt
|
||||
from gatehouse_app.models import PasswordResetToken, AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
|
||||
data = request.get_json() or {}
|
||||
token_value = (data.get("token") or "").strip()
|
||||
new_password = data.get("password") or ""
|
||||
password_confirm = data.get("password_confirm") or ""
|
||||
|
||||
if not token_value or not new_password:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Token and new password are required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
if new_password != password_confirm:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Passwords do not match",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
if len(new_password) < 8:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Password must be at least 8 characters",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
reset_token = PasswordResetToken.query.filter_by(token=token_value).first()
|
||||
if not reset_token or not reset_token.is_valid:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="This password reset link is invalid or has expired.",
|
||||
status=400,
|
||||
error_type="INVALID_TOKEN",
|
||||
)
|
||||
|
||||
try:
|
||||
user = reset_token.user
|
||||
# Update the password hash on the authentication method
|
||||
auth_method = AuthenticationMethod.query.filter_by(
|
||||
user_id=user.id,
|
||||
method_type=AuthMethodType.PASSWORD,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
if auth_method:
|
||||
auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
||||
from gatehouse_app.extensions import db
|
||||
db.session.add(auth_method)
|
||||
|
||||
reset_token.consume()
|
||||
_pw_logger.info(f"Password reset for user {user.id}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="Your password has been reset. You can now sign in with your new password.",
|
||||
)
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error resetting password: {exc}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred while resetting your password.",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/verify-email", methods=["POST"])
|
||||
def verify_email():
|
||||
"""Verify a user's email address using a verification token.
|
||||
|
||||
Request body:
|
||||
token: Email verification token
|
||||
|
||||
Returns:
|
||||
200: Email verified successfully
|
||||
400: Invalid or expired token
|
||||
"""
|
||||
from gatehouse_app.models import EmailVerificationToken
|
||||
|
||||
data = request.get_json() or {}
|
||||
token_value = (data.get("token") or "").strip()
|
||||
|
||||
if not token_value:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Verification token is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
verify_token = EmailVerificationToken.query.filter_by(token=token_value).first()
|
||||
if not verify_token or not verify_token.is_valid:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="This verification link is invalid or has expired.",
|
||||
status=400,
|
||||
error_type="INVALID_TOKEN",
|
||||
)
|
||||
|
||||
try:
|
||||
user = verify_token.user
|
||||
user.email_verified = True
|
||||
from gatehouse_app.extensions import db
|
||||
db.session.add(user)
|
||||
verify_token.consume()
|
||||
_pw_logger.info(f"Email verified for user {user.id}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="Your email has been verified. You can now sign in.",
|
||||
)
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error verifying email: {exc}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred while verifying your email.",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/resend-verification", methods=["POST"])
|
||||
def resend_verification():
|
||||
"""Resend email verification link.
|
||||
|
||||
Always returns 200 to avoid leaking account existence.
|
||||
|
||||
Request body:
|
||||
email: User email address
|
||||
|
||||
Returns:
|
||||
200: Verification email sent (or silently no-op)
|
||||
"""
|
||||
from gatehouse_app.models import User, EmailVerificationToken
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Email is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
user = User.query.filter_by(email=email, deleted_at=None).first()
|
||||
if user and not user.email_verified:
|
||||
try:
|
||||
verify_token = EmailVerificationToken.generate(user_id=user.id)
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
|
||||
subject = "Verify your Gatehouse email address"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"Please verify your email address by clicking the link below (valid for 24 hours):\n"
|
||||
f"{verify_link}\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
_pw_logger.info(f"Verification email sent for user {user.id}")
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error sending verification email: {exc}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="If an account exists for this email and is not yet verified, you will receive a verification link shortly.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Account Activation (separate from email-verification)
|
||||
# =============================================================================
|
||||
|
||||
@api_v1_bp.route("/auth/activate", methods=["POST"])
|
||||
def activate_account():
|
||||
"""Activate a user account via a one-time activation code.
|
||||
|
||||
Request body:
|
||||
code – the activation_key from the welcome email
|
||||
|
||||
Returns:
|
||||
200: Account activated, session token returned
|
||||
400: Missing code
|
||||
404: Invalid or already-used code
|
||||
"""
|
||||
import secrets
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
data = request.get_json() or {}
|
||||
code = (data.get("code") or "").strip()
|
||||
if not code:
|
||||
return api_response(success=False, message="Activation code is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
user = User.query.filter_by(activation_key=code, deleted_at=None).first()
|
||||
if not user:
|
||||
return api_response(success=False, message="Invalid or expired activation code", status=404, error_type="NOT_FOUND")
|
||||
|
||||
user.activated = True
|
||||
user.activation_key = None # one-time use
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
_pw_logger.info(f"Account activated for user {user.id}")
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"token": user_session.token,
|
||||
"expires_at": user_session.expires_at.isoformat() + "Z"
|
||||
if user_session.expires_at.isoformat()[-1] != "Z"
|
||||
else user_session.expires_at.isoformat(),
|
||||
},
|
||||
message="Account activated successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/resend-activation", methods=["POST"])
|
||||
def resend_activation():
|
||||
"""Re-send an account activation email.
|
||||
|
||||
Always returns 200 to avoid leaking whether an account exists.
|
||||
|
||||
Request body:
|
||||
email – user email address
|
||||
"""
|
||||
import secrets
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
return api_response(success=False, message="Email is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
user = User.query.filter_by(email=email, deleted_at=None).first()
|
||||
if user and not user.activated:
|
||||
try:
|
||||
code = secrets.token_urlsafe(32)
|
||||
user.activation_key = code
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
app_url = current_app.config.get("APP_URL", current_app.config.get("FRONTEND_URL", "http://localhost:8080"))
|
||||
activate_link = f"{app_url}/activate?code={code}"
|
||||
subject = "Activate your Gatehouse account"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"Please activate your Gatehouse account by clicking the link below:\n"
|
||||
f"{activate_link}\n\n"
|
||||
f"If you did not create an account, you can safely ignore this email.\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
|
||||
_pw_logger.info(f"Activation email re-sent to {user.id}")
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error re-sending activation email: {exc}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="If an unactivated account exists for this email, you will receive a new activation link shortly.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Token retrieval / redirect (for CLI / external tools)
|
||||
# =============================================================================
|
||||
|
||||
@api_v1_bp.route("/auth/token", methods=["GET"])
|
||||
@login_required
|
||||
def get_token():
|
||||
"""Return the current session token, optionally redirecting to a URL.
|
||||
|
||||
Query parameters:
|
||||
redirect – optional URL to redirect to with the token appended as
|
||||
a query param: ``<redirect>?token=<token>``
|
||||
|
||||
Returns:
|
||||
200: JSON ``{"token": "<token>"}`` (no redirect given)
|
||||
302: Redirect to ``<redirect>?token=<token>``
|
||||
"""
|
||||
from flask import redirect as flask_redirect
|
||||
|
||||
token = g.current_session.token
|
||||
redirect_url = request.args.get("redirect", "").strip()
|
||||
|
||||
if redirect_url:
|
||||
sep = "&" if "?" in redirect_url else "?"
|
||||
return flask_redirect(f"{redirect_url}{sep}token={token}", code=302)
|
||||
|
||||
return api_response(data={"token": token}, message="Token retrieved")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Department endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import Schema, fields, validate, ValidationError
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
@@ -422,7 +423,7 @@ def add_department_member(org_id, dept_id):
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if already a member
|
||||
# Check if already an active member
|
||||
existing = DepartmentMembership.query.filter_by(
|
||||
user_id=user.id,
|
||||
department_id=dept_id,
|
||||
@@ -437,12 +438,23 @@ def add_department_member(org_id, dept_id):
|
||||
error_type="CONFLICT",
|
||||
)
|
||||
|
||||
# Add member
|
||||
membership = DepartmentMembership(
|
||||
user_id=user.id,
|
||||
department_id=dept_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
# Check for a previously soft-deleted row and resurrect it instead of inserting
|
||||
soft_deleted = DepartmentMembership.query.filter(
|
||||
DepartmentMembership.user_id == user.id,
|
||||
DepartmentMembership.department_id == dept_id,
|
||||
DepartmentMembership.deleted_at.isnot(None)
|
||||
).first()
|
||||
|
||||
if soft_deleted:
|
||||
soft_deleted.deleted_at = None
|
||||
membership = soft_deleted
|
||||
else:
|
||||
membership = DepartmentMembership(
|
||||
user_id=user.id,
|
||||
department_id=dept_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
member_dict = membership.to_dict()
|
||||
@@ -560,3 +572,128 @@ def get_department_principals(org_id, dept_id):
|
||||
},
|
||||
message="Principals retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Department Certificate Policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/cert-policy", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def get_dept_cert_policy(org_id, dept_id):
|
||||
"""Get the certificate issuance policy for a department (admin only)."""
|
||||
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
|
||||
|
||||
dept = Department.query.filter_by(
|
||||
id=dept_id, organization_id=org_id, deleted_at=None
|
||||
).first()
|
||||
if not dept:
|
||||
return api_response(success=False, message="Department not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
policy = DepartmentCertPolicy.query.filter(
|
||||
DepartmentCertPolicy.department_id == dept_id,
|
||||
DepartmentCertPolicy.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if policy:
|
||||
data = policy.to_dict()
|
||||
else:
|
||||
# Return default (all standard extensions, no user expiry choice)
|
||||
data = {
|
||||
"department_id": str(dept_id),
|
||||
"allow_user_expiry": False,
|
||||
"default_expiry_hours": 1,
|
||||
"max_expiry_hours": 24,
|
||||
"allowed_extensions": list(STANDARD_EXTENSIONS),
|
||||
"custom_extensions": [],
|
||||
"all_extensions": list(STANDARD_EXTENSIONS),
|
||||
"standard_extensions": list(STANDARD_EXTENSIONS),
|
||||
}
|
||||
|
||||
return api_response(data={"cert_policy": data}, message="Certificate policy retrieved")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/cert-policy", methods=["PUT"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def set_dept_cert_policy(org_id, dept_id):
|
||||
"""Create or update the certificate issuance policy for a department (admin only)."""
|
||||
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
|
||||
|
||||
dept = Department.query.filter_by(
|
||||
id=dept_id, organization_id=org_id, deleted_at=None
|
||||
).first()
|
||||
if not dept:
|
||||
return api_response(success=False, message="Department not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
body = request.get_json() or {}
|
||||
|
||||
# Validate expiry values
|
||||
default_expiry = body.get("default_expiry_hours")
|
||||
max_expiry = body.get("max_expiry_hours")
|
||||
if default_expiry is not None:
|
||||
try:
|
||||
default_expiry = int(default_expiry)
|
||||
if default_expiry < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
return api_response(success=False, message="default_expiry_hours must be a positive integer", status=400, error_type="VALIDATION_ERROR")
|
||||
if max_expiry is not None:
|
||||
try:
|
||||
max_expiry = int(max_expiry)
|
||||
if max_expiry < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
return api_response(success=False, message="max_expiry_hours must be a positive integer", status=400, error_type="VALIDATION_ERROR")
|
||||
if default_expiry and max_expiry and default_expiry > max_expiry:
|
||||
return api_response(success=False, message="default_expiry_hours cannot exceed max_expiry_hours", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
# Validate allowed_extensions — must be subset of STANDARD_EXTENSIONS
|
||||
allowed_extensions = body.get("allowed_extensions")
|
||||
if allowed_extensions is not None:
|
||||
if not isinstance(allowed_extensions, list):
|
||||
return api_response(success=False, message="allowed_extensions must be a list", status=400, error_type="VALIDATION_ERROR")
|
||||
invalid_ext = [e for e in allowed_extensions if e not in STANDARD_EXTENSIONS]
|
||||
if invalid_ext:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Invalid standard extensions: {', '.join(invalid_ext)}. Valid: {', '.join(STANDARD_EXTENSIONS)}",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Validate custom_extensions — plain strings
|
||||
custom_extensions = body.get("custom_extensions")
|
||||
if custom_extensions is not None:
|
||||
if not isinstance(custom_extensions, list) or not all(isinstance(e, str) for e in custom_extensions):
|
||||
return api_response(success=False, message="custom_extensions must be a list of strings", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
policy = DepartmentCertPolicy.query.filter(
|
||||
DepartmentCertPolicy.department_id == dept_id,
|
||||
DepartmentCertPolicy.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if policy is None:
|
||||
policy = DepartmentCertPolicy(department_id=dept_id)
|
||||
db.session.add(policy)
|
||||
|
||||
if "allow_user_expiry" in body:
|
||||
policy.allow_user_expiry = bool(body["allow_user_expiry"])
|
||||
if default_expiry is not None:
|
||||
policy.default_expiry_hours = default_expiry
|
||||
if max_expiry is not None:
|
||||
policy.max_expiry_hours = max_expiry
|
||||
if allowed_extensions is not None:
|
||||
policy.allowed_extensions = list(allowed_extensions)
|
||||
flag_modified(policy, "allowed_extensions")
|
||||
if custom_extensions is not None:
|
||||
policy.custom_extensions = list(custom_extensions)
|
||||
flag_modified(policy, "custom_extensions")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={"cert_policy": policy.to_dict()}, message="Certificate policy saved")
|
||||
|
||||
|
||||
@@ -101,24 +101,24 @@ def token_please():
|
||||
"""
|
||||
CLI token acquisition endpoint.
|
||||
|
||||
Initiates an OAuth login flow and, on success, redirects the user's browser
|
||||
to the CLI's local callback server (redirect_url) with the session token
|
||||
appended, e.g.: http://127.0.0.1:8250/?token=<SESSION_TOKEN>
|
||||
Redirects the user's browser to the Gatehouse login page so they can
|
||||
authenticate using any method (password, OAuth, passkey, TOTP, etc.).
|
||||
On successful login the frontend delivers the session token directly to
|
||||
the CLI's local callback server.
|
||||
|
||||
This endpoint is designed for CLI clients that:
|
||||
1. Start a local HTTP server on LISTENER_SERVER_PORT (e.g. 8250)
|
||||
2. Open a browser to /api/v1/token_please?redirect_url=http://127.0.0.1:8250/?token=
|
||||
3. Wait for the browser to POST the token back to their local server
|
||||
3. Wait for the browser to deliver the token to their local server
|
||||
|
||||
Query parameters:
|
||||
redirect_url: Local callback URL where the token will be appended
|
||||
provider: OAuth provider to use (default: 'google')
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
import secrets
|
||||
from urllib.parse import urlencode, quote
|
||||
from flask import current_app, redirect as flask_redirect
|
||||
|
||||
redirect_url = request.args.get("redirect_url", "").strip()
|
||||
provider = request.args.get("provider", "google").lower()
|
||||
|
||||
if not redirect_url:
|
||||
return api_response(
|
||||
@@ -139,26 +139,92 @@ def token_please():
|
||||
error_type="INVALID_REDIRECT_URL",
|
||||
)
|
||||
|
||||
# Store the CLI redirect URL in Redis keyed by a short-lived token so the
|
||||
# frontend can retrieve it after login without it being visible in the URL.
|
||||
cli_token = secrets.token_urlsafe(32)
|
||||
try:
|
||||
provider_type = get_provider_type(provider)
|
||||
auth_url, state = OAuthFlowService.initiate_login_flow(
|
||||
provider_type=provider_type,
|
||||
organization_id=None,
|
||||
redirect_uri=None,
|
||||
)
|
||||
except (OAuthFlowError, ExternalAuthError) as e:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"cli_redirect:{cli_token}", _OAUTH_BRIDGE_TTL, redirect_url)
|
||||
else:
|
||||
logger.warning("Redis not available; passing cli_redirect directly in URL")
|
||||
cli_token = None
|
||||
except Exception:
|
||||
cli_token = None
|
||||
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
|
||||
|
||||
if cli_token:
|
||||
# Pass an opaque token; the frontend exchanges it for the real URL via
|
||||
# GET /api/v1/cli/redirect-url?token=<cli_token>
|
||||
login_url = f"{frontend_url}/login?cli_token={cli_token}"
|
||||
else:
|
||||
# Fallback: put the redirect URL directly (still localhost-only, validated above)
|
||||
login_url = f"{frontend_url}/login?cli_redirect={quote(redirect_url, safe='')}"
|
||||
|
||||
logger.info(f"CLI token_please: redirecting browser to Gatehouse login page")
|
||||
return flask_redirect(login_url, code=302)
|
||||
|
||||
|
||||
@api_v1_bp.route("/cli/redirect-url", methods=["GET"])
|
||||
def cli_redirect_url_lookup():
|
||||
"""
|
||||
Exchange a short-lived cli_token for the CLI's local redirect URL.
|
||||
|
||||
Called by the frontend LoginPage after it detects the cli_token query
|
||||
param so it can obtain the actual CLI callback URL from Redis without
|
||||
exposing it in the browser URL bar.
|
||||
|
||||
Query parameters:
|
||||
token: The cli_token issued by /token_please
|
||||
|
||||
Returns:
|
||||
200: { "redirect_url": "http://127.0.0.1:8250/?token=" }
|
||||
400: Missing token
|
||||
404: Token not found or expired
|
||||
"""
|
||||
cli_token = request.args.get("token", "").strip()
|
||||
if not cli_token:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=getattr(e, "message", str(e)),
|
||||
status=getattr(e, "status_code", 400),
|
||||
error_type=getattr(e, "error_type", "OAUTH_ERROR"),
|
||||
message="token query parameter is required",
|
||||
status=400,
|
||||
error_type="MISSING_TOKEN",
|
||||
)
|
||||
|
||||
# Store the CLI redirect URL so the callback can use it
|
||||
_store_cli_redirect(state, redirect_url)
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"cli_redirect:{cli_token}"
|
||||
val = rc.get(key)
|
||||
if val is None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="CLI token not found or expired",
|
||||
status=404,
|
||||
error_type="TOKEN_NOT_FOUND",
|
||||
)
|
||||
# Keep the key alive until the login actually completes (consume on use
|
||||
# would break multi-step auth like TOTP), so we leave it as-is.
|
||||
redirect_url = val.decode() if isinstance(val, bytes) else val
|
||||
return api_response(data={"redirect_url": redirect_url})
|
||||
except Exception as e:
|
||||
logger.error(f"cli_redirect_url_lookup error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Internal error looking up CLI token",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
logger.info(f"CLI token_please: provider={provider}, redirect_url={redirect_url}, redirecting to OAuth")
|
||||
return flask_redirect(auth_url, code=302)
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Redis not available",
|
||||
status=503,
|
||||
error_type="SERVICE_UNAVAILABLE",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -175,7 +241,7 @@ def list_providers():
|
||||
200: List of providers with their configuration status
|
||||
401: Not authenticated
|
||||
"""
|
||||
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
|
||||
|
||||
# Check app-level provider configs (ApplicationProviderConfig)
|
||||
@@ -1173,3 +1239,203 @@ def _get_provider_endpoints(provider_type: AuthMethodType):
|
||||
"UNSUPPORTED_PROVIDER",
|
||||
400,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin: Application-level OAuth Provider Management
|
||||
# =============================================================================
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
|
||||
@login_required
|
||||
def admin_list_app_providers():
|
||||
"""List all application-level OAuth provider configurations (admin only).
|
||||
|
||||
Returns:
|
||||
200: List of providers with client_id and enabled status
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
"""
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
# Verify caller is admin in any org
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Admin access required",
|
||||
status=403,
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
|
||||
PROVIDERS = [
|
||||
{"id": "google", "name": "Google"},
|
||||
{"id": "github", "name": "GitHub"},
|
||||
{"id": "microsoft", "name": "Microsoft"},
|
||||
]
|
||||
|
||||
db_configs = {
|
||||
c.provider_type: c
|
||||
for c in ApplicationProviderConfig.query.all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for p in PROVIDERS:
|
||||
cfg = db_configs.get(p["id"])
|
||||
result.append({
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"is_configured": cfg is not None,
|
||||
"is_enabled": cfg.is_enabled if cfg else False,
|
||||
"client_id": cfg.client_id if cfg else None,
|
||||
})
|
||||
|
||||
return api_response(
|
||||
data={"providers": result},
|
||||
message="OAuth providers retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["PUT"])
|
||||
@login_required
|
||||
def admin_configure_app_provider(provider: str):
|
||||
"""Create or update an application-level OAuth provider config (admin only).
|
||||
|
||||
Args:
|
||||
provider: Provider type (google, github, microsoft)
|
||||
|
||||
Request body:
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret (optional — omit to keep existing)
|
||||
is_enabled: Whether the provider is enabled (default: true)
|
||||
|
||||
Returns:
|
||||
200: Provider configuration updated
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
"""
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
SUPPORTED = ["google", "github", "microsoft"]
|
||||
if provider not in SUPPORTED:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Unsupported provider. Must be one of: {', '.join(SUPPORTED)}",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Verify caller is admin in any org
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Admin access required",
|
||||
status=403,
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
|
||||
data = request.json or {}
|
||||
client_id = (data.get("client_id") or "").strip()
|
||||
client_secret = (data.get("client_secret") or "").strip()
|
||||
is_enabled = data.get("is_enabled", True)
|
||||
|
||||
if not client_id:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="client_id is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
|
||||
if cfg:
|
||||
cfg.client_id = client_id
|
||||
if client_secret:
|
||||
cfg.set_client_secret(client_secret)
|
||||
cfg.is_enabled = bool(is_enabled)
|
||||
db.session.commit()
|
||||
else:
|
||||
cfg = ApplicationProviderConfig(
|
||||
provider_type=provider,
|
||||
client_id=client_id,
|
||||
is_enabled=bool(is_enabled),
|
||||
)
|
||||
if client_secret:
|
||||
cfg.set_client_secret(client_secret)
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"provider": {
|
||||
"id": provider,
|
||||
"client_id": cfg.client_id,
|
||||
"is_enabled": cfg.is_enabled,
|
||||
}
|
||||
},
|
||||
message=f"{provider.capitalize()} OAuth provider configured successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["DELETE"])
|
||||
@login_required
|
||||
def admin_delete_app_provider(provider: str):
|
||||
"""Delete an application-level OAuth provider config (admin only).
|
||||
|
||||
Args:
|
||||
provider: Provider type (google, github, microsoft)
|
||||
|
||||
Returns:
|
||||
200: Provider configuration deleted
|
||||
404: Provider not found
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
"""
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
# Verify caller is admin in any org
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Admin access required",
|
||||
status=403,
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
|
||||
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
|
||||
if not cfg:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Provider '{provider}' is not configured",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
db.session.delete(cfg)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(
|
||||
message=f"{provider.capitalize()} OAuth provider configuration removed",
|
||||
)
|
||||
|
||||
@@ -381,6 +381,7 @@ def update_member_role(org_id, user_id):
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def get_organization_audit_logs(org_id):
|
||||
"""
|
||||
@@ -397,7 +398,7 @@ def get_organization_audit_logs(org_id):
|
||||
403: Not a member / insufficient permissions
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
|
||||
# Ensure org exists and user is a member (full_access_required handles this)
|
||||
OrganizationService.get_organization_by_id(org_id)
|
||||
@@ -492,7 +493,7 @@ def create_org_invite(org_id):
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
invite_link = f"{app_url}/invite?token={invite.token}"
|
||||
|
||||
NotificationService._send_email(
|
||||
email_sent = NotificationService._send_email(
|
||||
to_address=email,
|
||||
subject=f"You're invited to join {org.name} on Gatehouse",
|
||||
body=(
|
||||
@@ -503,13 +504,103 @@ def create_org_invite(org_id):
|
||||
),
|
||||
)
|
||||
|
||||
# In dev mode email may not be configured — always log the link so it's findable
|
||||
import logging
|
||||
if not email_sent:
|
||||
logging.getLogger(__name__).warning(
|
||||
f"[INVITE LINK] Email not sent (EMAIL_ENABLED=False or SMTP down). "
|
||||
f"Invite for {email} → {invite_link}"
|
||||
)
|
||||
else:
|
||||
logging.getLogger(__name__).info(
|
||||
f"[INVITE] Email sent successfully to {email}"
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"invite": {
|
||||
"id": invite.id,
|
||||
"email": invite.email,
|
||||
"role": invite.role,
|
||||
"expires_at": invite.expires_at.isoformat() + "Z",
|
||||
# Only include invite_link when email delivery failed — signals frontend to show copy dialog
|
||||
**({"invite_link": invite_link} if not email_sent else {}),
|
||||
}
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={"invite": {"id": invite.id, "email": invite.email, "role": invite.role, "expires_at": invite.expires_at.isoformat() + "Z"}},
|
||||
data=response_data,
|
||||
message="Invite sent successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/invites", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def list_org_invites(org_id):
|
||||
"""List pending invite tokens for an organization.
|
||||
|
||||
Returns:
|
||||
200: List of invites
|
||||
403: Not an admin
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken, Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404)
|
||||
|
||||
invites = (
|
||||
OrgInviteToken.query.filter_by(organization_id=org_id)
|
||||
.filter(OrgInviteToken.accepted_at == None)
|
||||
.filter(OrgInviteToken.deleted_at == None)
|
||||
.all()
|
||||
)
|
||||
|
||||
def invite_to_dict(inv):
|
||||
return {
|
||||
"id": inv.id,
|
||||
"email": inv.email,
|
||||
"role": inv.role,
|
||||
"invited_by_id": inv.invited_by_id,
|
||||
"created_at": inv.created_at.isoformat() + "Z",
|
||||
"expires_at": inv.expires_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={"invites": [invite_to_dict(i) for i in invites]},
|
||||
message="Invites retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/invites/<invite_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def cancel_org_invite(org_id, invite_id):
|
||||
"""Cancel (soft-delete) an organization invite.
|
||||
|
||||
Returns:
|
||||
200: Invite cancelled
|
||||
403: Not an admin
|
||||
404: Invite not found
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken, Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404)
|
||||
|
||||
invite = OrgInviteToken.query.filter_by(id=invite_id, organization_id=org_id, deleted_at=None).first()
|
||||
if not invite:
|
||||
return api_response(success=False, message="Invite not found", status=404)
|
||||
|
||||
# Soft delete the invite so it's no longer usable
|
||||
invite.delete(soft=True)
|
||||
|
||||
return api_response(data={}, message="Invite cancelled")
|
||||
|
||||
|
||||
@api_v1_bp.route("/invites/<token>", methods=["GET"])
|
||||
def get_invite(token):
|
||||
"""Get invite details by token.
|
||||
@@ -518,17 +609,20 @@ def get_invite(token):
|
||||
200: Invite details (org name, email)
|
||||
400: Invalid or expired token
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken
|
||||
from gatehouse_app.models import OrgInviteToken, User
|
||||
|
||||
invite = OrgInviteToken.query.filter_by(token=token).first()
|
||||
if not invite or not invite.is_valid:
|
||||
return api_response(success=False, message="This invitation link is invalid or has expired.", status=400, error_type="INVALID_TOKEN")
|
||||
|
||||
user_exists = User.query.filter_by(email=invite.email, deleted_at=None).first() is not None
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"email": invite.email,
|
||||
"organization": {"id": invite.organization_id, "name": invite.organization.name},
|
||||
"role": invite.role,
|
||||
"user_exists": user_exists,
|
||||
},
|
||||
message="Invite found",
|
||||
)
|
||||
@@ -617,12 +711,14 @@ def accept_invite(token):
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def list_org_clients(org_id):
|
||||
"""List OIDC clients for an organization.
|
||||
|
||||
Returns:
|
||||
200: List of OIDC clients
|
||||
403: Not a member
|
||||
403: Not an admin
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models import OIDCClient, Organization
|
||||
@@ -838,16 +934,18 @@ def get_system_audit_logs():
|
||||
success – "true"/"false"
|
||||
q – free-text search on description
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
current_user = g.current_user
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
|
||||
# Check if the user is an owner of any org to grant admin-level access
|
||||
is_admin = OrganizationMember.query.filter_by(
|
||||
user_id=current_user.id, role="OWNER"
|
||||
# Check if the user is an admin or owner of any org to grant admin-level access
|
||||
is_admin = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first() is not None
|
||||
|
||||
query = AuditLog.query
|
||||
@@ -905,7 +1003,7 @@ def get_my_audit_logs():
|
||||
per_page – results per page (default 50, max 200)
|
||||
action – filter by AuditAction value
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
|
||||
current_user = g.current_user
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
@@ -947,8 +1045,8 @@ def list_organization_roles(org_id):
|
||||
401: Not authenticated
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
@@ -996,7 +1094,7 @@ def assign_role_to_member(org_id, role_name):
|
||||
403: Not an admin/owner
|
||||
404: Org or member not found
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
try:
|
||||
@@ -1040,7 +1138,7 @@ def remove_role_from_member(org_id, role_name, user_id):
|
||||
403: Not an admin/owner
|
||||
404: Org or member not found
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
try:
|
||||
@@ -1074,8 +1172,8 @@ def list_org_cas(org_id):
|
||||
403: Not admin/owner
|
||||
404: Org not found
|
||||
"""
|
||||
from gatehouse_app.models.ca import CA
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
@@ -1104,8 +1202,8 @@ def update_org_ca(org_id, ca_id):
|
||||
403: Not admin/owner
|
||||
404: Org or CA not found
|
||||
"""
|
||||
from gatehouse_app.models.ca import CA
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from marshmallow import Schema, fields, validate, ValidationError
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
@@ -1192,8 +1290,8 @@ def create_org_ca(org_id):
|
||||
403: Not admin/owner
|
||||
404: Org not found
|
||||
"""
|
||||
from gatehouse_app.models.ca import CA, KeyType
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, KeyType
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
from marshmallow import Schema, fields as ma_fields, validate, ValidationError as MaValidationError
|
||||
from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey
|
||||
@@ -1227,7 +1325,7 @@ def create_org_ca(org_id):
|
||||
)
|
||||
|
||||
# Enforce one CA per type per org
|
||||
from gatehouse_app.models.ca import CaType
|
||||
from gatehouse_app.models.ssh_ca.ca import CaType
|
||||
ca_type_val = data["ca_type"]
|
||||
existing_type = CA.query.filter_by(
|
||||
organization_id=org_id, deleted_at=None
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -458,12 +458,22 @@ def add_principal_member(org_id, principal_id):
|
||||
error_type="CONFLICT",
|
||||
)
|
||||
|
||||
# Add member
|
||||
membership = PrincipalMembership(
|
||||
user_id=user.id,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
soft_deleted = PrincipalMembership.query.filter(
|
||||
PrincipalMembership.user_id == user.id,
|
||||
PrincipalMembership.principal_id == principal_id,
|
||||
PrincipalMembership.deleted_at.isnot(None)
|
||||
).first()
|
||||
|
||||
if soft_deleted:
|
||||
soft_deleted.deleted_at = None
|
||||
membership = soft_deleted
|
||||
else:
|
||||
membership = PrincipalMembership(
|
||||
user_id=user.id,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
member_dict = membership.to_dict()
|
||||
@@ -665,7 +675,7 @@ def link_principal_to_department(org_id, principal_id, dept_id):
|
||||
soft_deleted = DepartmentPrincipal.query.filter(
|
||||
DepartmentPrincipal.department_id == dept_id,
|
||||
DepartmentPrincipal.principal_id == principal_id,
|
||||
DepartmentPrincipal.deleted_at != None, # noqa: E711
|
||||
DepartmentPrincipal.deleted_at.isnot(None),
|
||||
).first()
|
||||
|
||||
try:
|
||||
@@ -678,13 +688,8 @@ def link_principal_to_department(org_id, principal_id, dept_id):
|
||||
)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
from gatehouse_app.extensions import db as _db
|
||||
try:
|
||||
_db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Failed to link principal to department",
|
||||
@@ -693,6 +698,10 @@ def link_principal_to_department(org_id, principal_id, dept_id):
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"principal": principal.to_dict(),
|
||||
"department": dept.to_dict(),
|
||||
},
|
||||
message="Principal linked to department successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
+127
-10
@@ -30,7 +30,7 @@ ssh_ca_service = SSHCASigningService()
|
||||
def _get_org_ca_for_user(user):
|
||||
"""Return the active DB CA for the user's first org, or None."""
|
||||
try:
|
||||
from gatehouse_app.models.ca import CA
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
org_ids = [m.organization_id for m in user.organization_memberships]
|
||||
if not org_ids:
|
||||
return None
|
||||
@@ -51,7 +51,7 @@ def _get_or_create_system_ca():
|
||||
The record is created on first use and has no ``organization_id``.
|
||||
"""
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.ca import CA, KeyType
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, KeyType
|
||||
from gatehouse_app.config.ssh_ca_config import get_ssh_ca_config
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
import os
|
||||
@@ -132,8 +132,8 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
||||
|
||||
try:
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus
|
||||
from gatehouse_app.models.ca import CertType
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
|
||||
from gatehouse_app.models.ssh_ca.ca import CertType
|
||||
|
||||
try:
|
||||
resolved_cert_type = CertType(cert_type_str)
|
||||
@@ -172,6 +172,87 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
||||
|
||||
|
||||
|
||||
def _get_merged_dept_cert_policy(user_id):
|
||||
"""Return a merged cert policy view for the given user across all their departments.
|
||||
|
||||
Rules for merging when a user belongs to multiple departments:
|
||||
- ``allow_user_expiry``: True only if ALL departments allow it.
|
||||
- ``default_expiry_hours``: minimum across departments (most restrictive).
|
||||
- ``max_expiry_hours``: minimum across departments (most restrictive).
|
||||
- ``extensions``: intersection — only extensions allowed by ALL departments.
|
||||
|
||||
Returns a plain dict with keys:
|
||||
allow_user_expiry, default_expiry_hours, max_expiry_hours, extensions
|
||||
Or None if the user has no department memberships or no policies are configured.
|
||||
"""
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership
|
||||
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
|
||||
|
||||
memberships = DepartmentMembership.query.filter_by(user_id=user_id, deleted_at=None).all()
|
||||
dept_ids = [m.department_id for m in memberships if m.department and m.department.deleted_at is None]
|
||||
if not dept_ids:
|
||||
return None
|
||||
|
||||
policies = DepartmentCertPolicy.query.filter(
|
||||
DepartmentCertPolicy.department_id.in_(dept_ids),
|
||||
DepartmentCertPolicy.deleted_at.is_(None),
|
||||
).all()
|
||||
if not policies:
|
||||
return None
|
||||
|
||||
allow_user_expiry = all(p.allow_user_expiry for p in policies)
|
||||
default_expiry_hours = min(p.default_expiry_hours for p in policies)
|
||||
max_expiry_hours = min(p.max_expiry_hours for p in policies)
|
||||
|
||||
# Intersection of all_extensions() across policies
|
||||
ext_sets = [set(p.all_extensions()) for p in policies]
|
||||
extensions = list(ext_sets[0].intersection(*ext_sets[1:]))
|
||||
|
||||
return {
|
||||
"allow_user_expiry": allow_user_expiry,
|
||||
"default_expiry_hours": default_expiry_hours,
|
||||
"max_expiry_hours": max_expiry_hours,
|
||||
"extensions": extensions,
|
||||
}
|
||||
|
||||
|
||||
@ssh_bp.route('/dept-cert-policy', methods=['GET'])
|
||||
@login_required
|
||||
def get_my_dept_cert_policy():
|
||||
"""Return the merged department certificate policy for the current user.
|
||||
|
||||
Admins always get allow_user_expiry=True so the frontend shows the expiry
|
||||
picker for them regardless of the member-facing toggle setting.
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
# Check if caller is an org admin/owner
|
||||
is_org_admin = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first() is not None
|
||||
|
||||
policy = _get_merged_dept_cert_policy(user_id)
|
||||
if policy is None:
|
||||
policy = {
|
||||
"allow_user_expiry": is_org_admin, # admins default to True even without a dept policy
|
||||
"default_expiry_hours": 1,
|
||||
"max_expiry_hours": 24,
|
||||
"extensions": list(STANDARD_EXTENSIONS),
|
||||
}
|
||||
elif is_org_admin:
|
||||
# Override allow_user_expiry for admins — they can always pick
|
||||
policy = {**policy, "allow_user_expiry": True}
|
||||
|
||||
return api_response(data={"policy": policy}, message="Certificate policy retrieved")
|
||||
|
||||
|
||||
@ssh_bp.route('/keys', methods=['GET'])
|
||||
@login_required
|
||||
def list_ssh_keys():
|
||||
@@ -375,6 +456,16 @@ def sign_certificate():
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
# ── Check account suspension ──────────────────────────────────────────────
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status == UserStatus.SUSPENDED:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Your account is suspended. Contact an administrator.",
|
||||
status=403,
|
||||
error_type="ACCOUNT_SUSPENDED",
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return api_response(success=False, message="No JSON data provided", status=400, error_type="BAD_REQUEST")
|
||||
@@ -385,9 +476,9 @@ def sign_certificate():
|
||||
expiry_hours = data.get('expiry_hours')
|
||||
|
||||
# ── Resolve which principals the user is allowed to use ──────────────────
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
allowed_principal_names = set()
|
||||
@@ -468,12 +559,38 @@ def sign_certificate():
|
||||
db_ca = _get_org_ca_for_user(user)
|
||||
ca_private_key = db_ca.private_key if db_ca else None
|
||||
|
||||
# Determine if the caller is an org admin/owner (admins can always choose expiry)
|
||||
is_org_admin = any(
|
||||
om.role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
|
||||
for om in memberships
|
||||
if om.organization and om.organization.deleted_at is None
|
||||
)
|
||||
|
||||
# ── Apply department certificate policy ───────────────────────────────────
|
||||
dept_policy = _get_merged_dept_cert_policy(user_id)
|
||||
if dept_policy:
|
||||
if is_org_admin:
|
||||
# Admins can always choose their own expiry, but still capped at dept max
|
||||
if expiry_hours is not None:
|
||||
expiry_hours = min(int(expiry_hours), dept_policy["max_expiry_hours"])
|
||||
elif not dept_policy["allow_user_expiry"]:
|
||||
# Regular members: ignore user-requested expiry; use dept default
|
||||
expiry_hours = dept_policy["default_expiry_hours"]
|
||||
else:
|
||||
# Regular members allowed to pick, cap at dept maximum
|
||||
if expiry_hours is not None:
|
||||
expiry_hours = min(int(expiry_hours), dept_policy["max_expiry_hours"])
|
||||
policy_extensions = dept_policy["extensions"]
|
||||
else:
|
||||
policy_extensions = None # let signing service use its own defaults
|
||||
|
||||
signing_request = SSHCertificateSigningRequest(
|
||||
ssh_public_key=ssh_key.payload,
|
||||
principals=principals,
|
||||
cert_type=cert_type,
|
||||
key_id=key_id,
|
||||
expiry_hours=int(expiry_hours) if expiry_hours else None,
|
||||
extensions=policy_extensions,
|
||||
)
|
||||
validation_errors = signing_request.validate()
|
||||
if validation_errors:
|
||||
@@ -547,7 +664,7 @@ def list_certificates():
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
certs = (
|
||||
SSHCertificate.query
|
||||
.filter_by(user_id=user_id, deleted_at=None)
|
||||
@@ -577,7 +694,7 @@ def get_certificate(cert_id):
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
|
||||
if not cert:
|
||||
return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
|
||||
@@ -600,7 +717,7 @@ def revoke_certificate(cert_id):
|
||||
reason = data.get('reason', 'User requested revocation')
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
|
||||
if not cert:
|
||||
return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
|
||||
|
||||
+274
-17
@@ -142,18 +142,33 @@ def change_password():
|
||||
@full_access_required
|
||||
def get_my_organizations():
|
||||
"""
|
||||
Get all organizations current user is a member of.
|
||||
Get all organizations current user is a member of, including the user's role.
|
||||
|
||||
Returns:
|
||||
200: List of organizations
|
||||
200: List of organizations with role
|
||||
401: Not authenticated
|
||||
"""
|
||||
organizations = UserService.get_user_organizations(g.current_user)
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
user = g.current_user
|
||||
memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
|
||||
orgs = []
|
||||
for membership in memberships:
|
||||
org = membership.organization
|
||||
if not org or org.deleted_at is not None:
|
||||
continue
|
||||
org_dict = org.to_dict()
|
||||
org_dict["role"] = membership.role.value if hasattr(membership.role, "value") else str(membership.role)
|
||||
orgs.append(org_dict)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"organizations": [org.to_dict() for org in organizations],
|
||||
"count": len(organizations),
|
||||
"organizations": orgs,
|
||||
"count": len(orgs),
|
||||
},
|
||||
message="Organizations retrieved successfully",
|
||||
)
|
||||
@@ -179,9 +194,9 @@ def get_my_principals():
|
||||
}]
|
||||
}
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
user = g.current_user
|
||||
@@ -202,7 +217,9 @@ def get_my_principals():
|
||||
is_admin = role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
|
||||
|
||||
# Collect the user's effective principals for this org
|
||||
effective_principal_ids = set()
|
||||
# Track direct vs via-department separately
|
||||
direct_principal_ids = set()
|
||||
via_dept_principal_ids = set()
|
||||
|
||||
# Direct memberships
|
||||
direct = PrincipalMembership.query.filter_by(
|
||||
@@ -211,7 +228,7 @@ def get_my_principals():
|
||||
).all()
|
||||
for pm in direct:
|
||||
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
|
||||
effective_principal_ids.add(pm.principal_id)
|
||||
direct_principal_ids.add(pm.principal_id)
|
||||
|
||||
# Via department
|
||||
dept_memberships = DepartmentMembership.query.filter_by(
|
||||
@@ -226,7 +243,9 @@ def get_my_principals():
|
||||
).all()
|
||||
for dp in dept_principals:
|
||||
if dp.principal and dp.principal.deleted_at is None:
|
||||
effective_principal_ids.add(dp.principal_id)
|
||||
via_dept_principal_ids.add(dp.principal_id)
|
||||
|
||||
effective_principal_ids = direct_principal_ids | via_dept_principal_ids
|
||||
|
||||
# Fetch principal objects
|
||||
my_principals = []
|
||||
@@ -235,7 +254,16 @@ def get_my_principals():
|
||||
Principal.id.in_(list(effective_principal_ids)),
|
||||
Principal.deleted_at == None,
|
||||
).all()
|
||||
my_principals = [{"id": p.id, "name": p.name, "description": p.description} for p in my_p]
|
||||
my_principals = [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
# direct=True means removable via API; False=inherited via department
|
||||
"direct": p.id in direct_principal_ids,
|
||||
}
|
||||
for p in my_p
|
||||
]
|
||||
|
||||
# For admins/owners: also return all principals in the org
|
||||
all_principals = []
|
||||
@@ -263,6 +291,7 @@ def get_my_principals():
|
||||
|
||||
@api_v1_bp.route("/admin/users", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_list_users():
|
||||
"""List all users the caller has admin rights to see.
|
||||
|
||||
@@ -275,8 +304,8 @@ def admin_list_users():
|
||||
page – page number (default 1)
|
||||
per_page – page size (default 50, max 200)
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user import User as _User
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from sqlalchemy import or_
|
||||
|
||||
@@ -355,11 +384,12 @@ def admin_list_users():
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_get_user(user_id):
|
||||
"""Get a single user's profile (admin view with SSH keys)."""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user import User as _User
|
||||
from gatehouse_app.models.ssh_key import SSHKey
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
|
||||
|
||||
caller = g.current_user
|
||||
|
||||
@@ -388,3 +418,230 @@ def admin_get_user(user_id):
|
||||
},
|
||||
message="User retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/suspend", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_suspend_user(user_id):
|
||||
"""Suspend a user account (blocks CA issuance and login).
|
||||
|
||||
The caller must be an OWNER or ADMIN of an organization the target user belongs to.
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import UserStatus, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
target = _User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if target.id == caller.id:
|
||||
return api_response(success=False, message="Cannot suspend yourself", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
# Verify caller has admin access to a shared org
|
||||
target_org_ids = {m.organization_id for m in target.organization_memberships if m.deleted_at is None}
|
||||
admin_in_shared_org = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == caller.id,
|
||||
OrganizationMember.organization_id.in_(target_org_ids),
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first()
|
||||
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
if target.status == UserStatus.SUSPENDED:
|
||||
return api_response(success=False, message="User is already suspended", status=409, error_type="CONFLICT")
|
||||
|
||||
target.status = UserStatus.SUSPENDED
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_SUSPEND,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user",
|
||||
resource_id=str(target.id),
|
||||
description=f"Admin suspended user {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_email": target.email},
|
||||
)
|
||||
|
||||
return api_response(data={"user": target.to_dict()}, message="User suspended successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/unsuspend", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_unsuspend_user(user_id):
|
||||
"""Restore a suspended user account to active status."""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import UserStatus, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
target = _User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
# Verify caller has admin access to a shared org
|
||||
target_org_ids = {m.organization_id for m in target.organization_memberships if m.deleted_at is None}
|
||||
admin_in_shared_org = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == caller.id,
|
||||
OrganizationMember.organization_id.in_(target_org_ids),
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first()
|
||||
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
if target.status != UserStatus.SUSPENDED:
|
||||
return api_response(success=False, message="User is not suspended", status=409, error_type="CONFLICT")
|
||||
|
||||
target.status = UserStatus.ACTIVE
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_UNSUSPEND,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user",
|
||||
resource_id=str(target.id),
|
||||
description=f"Admin unsuspended user {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_email": target.email},
|
||||
)
|
||||
|
||||
return api_response(data={"user": target.to_dict()}, message="User unsuspended successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/invites", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_pending_invites():
|
||||
"""Return pending (unaccepted, non-expired) invitations for the current user's email."""
|
||||
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
||||
from datetime import datetime, timezone
|
||||
|
||||
user = g.current_user
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
invites = OrgInviteToken.query.filter(
|
||||
OrgInviteToken.email == user.email,
|
||||
OrgInviteToken.accepted_at.is_(None),
|
||||
OrgInviteToken.expires_at > now,
|
||||
OrgInviteToken.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"invites": [
|
||||
{
|
||||
"token": i.token,
|
||||
"organization": {"id": str(i.organization_id), "name": i.organization.name},
|
||||
"role": i.role,
|
||||
"expires_at": i.expires_at.isoformat(),
|
||||
}
|
||||
for i in invites
|
||||
]
|
||||
},
|
||||
message="Pending invitations retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/memberships", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_memberships():
|
||||
"""Return the current user's department and principal memberships across all orgs.
|
||||
|
||||
Returns:
|
||||
200: {
|
||||
orgs: [{
|
||||
org_id, org_name, role,
|
||||
departments: [{id, name, description}],
|
||||
principals: [{id, name, description, via_department: bool}]
|
||||
}]
|
||||
}
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal, Department
|
||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||
|
||||
user = g.current_user
|
||||
|
||||
memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
|
||||
orgs_result = []
|
||||
for membership in memberships:
|
||||
org = membership.organization
|
||||
if not org or org.deleted_at is not None:
|
||||
continue
|
||||
|
||||
# Departments the user belongs to
|
||||
dept_memberships = DepartmentMembership.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
user_depts = [
|
||||
dm.department for dm in dept_memberships
|
||||
if dm.department
|
||||
and dm.department.organization_id == org.id
|
||||
and dm.department.deleted_at is None
|
||||
]
|
||||
|
||||
# Principals: direct
|
||||
direct_pm = PrincipalMembership.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
direct_principal_ids = {
|
||||
pm.principal_id for pm in direct_pm
|
||||
if pm.principal
|
||||
and pm.principal.organization_id == org.id
|
||||
and pm.principal.deleted_at is None
|
||||
}
|
||||
|
||||
# Principals: via department
|
||||
via_dept_principal_ids = set()
|
||||
for dept in user_depts:
|
||||
for dp in DepartmentPrincipal.query.filter_by(department_id=dept.id, deleted_at=None).all():
|
||||
if dp.principal and dp.principal.deleted_at is None:
|
||||
via_dept_principal_ids.add(dp.principal_id)
|
||||
|
||||
all_principal_ids = direct_principal_ids | via_dept_principal_ids
|
||||
principals_list = []
|
||||
if all_principal_ids:
|
||||
for p in Principal.query.filter(
|
||||
Principal.id.in_(list(all_principal_ids)),
|
||||
Principal.deleted_at == None,
|
||||
).all():
|
||||
principals_list.append({
|
||||
"id": str(p.id),
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"via_department": p.id not in direct_principal_ids,
|
||||
})
|
||||
|
||||
role = membership.role
|
||||
orgs_result.append({
|
||||
"org_id": str(org.id),
|
||||
"org_name": org.name,
|
||||
"role": role.value if hasattr(role, "value") else role,
|
||||
"departments": [
|
||||
{"id": str(d.id), "name": d.name, "description": d.description}
|
||||
for d in user_depts
|
||||
],
|
||||
"principals": principals_list,
|
||||
})
|
||||
|
||||
return api_response(
|
||||
data={"orgs": orgs_result},
|
||||
message="Memberships retrieved",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ class LoginSchema(Schema):
|
||||
|
||||
email = fields.Email(required=True)
|
||||
password = fields.Str(required=True, validate=validate.Length(min=1))
|
||||
remember_me = fields.Bool(missing=False)
|
||||
remember_me = fields.Bool(load_default=False)
|
||||
|
||||
|
||||
class RefreshTokenSchema(Schema):
|
||||
@@ -78,7 +78,7 @@ class TOTPVerifySchema(Schema):
|
||||
"""Schema for TOTP code verification during login."""
|
||||
|
||||
code = fields.Str(required=True)
|
||||
is_backup_code = fields.Bool(missing=False)
|
||||
is_backup_code = fields.Bool(load_default=False)
|
||||
client_timestamp = fields.Int(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
|
||||
@@ -1,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
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from flask import request, g, current_app
|
||||
from gatehouse_app.extensions import db, bcrypt
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
|
||||
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
||||
|
||||
@@ -8,7 +8,7 @@ from flask import current_app
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models import User, AuthenticationMethod
|
||||
from gatehouse_app.models.authentication_method import (
|
||||
from gatehouse_app.models.auth.authentication_method import (
|
||||
OAuthState,
|
||||
ApplicationProviderConfig,
|
||||
OrganizationProviderOverride
|
||||
@@ -1210,12 +1210,35 @@ class ExternalAuthService:
|
||||
else:
|
||||
email_verified = data.get("email_verified", False)
|
||||
|
||||
sub = data.get("sub")
|
||||
|
||||
# Derive email from sub when the provider omits the email claim.
|
||||
# This happens with some OIDC servers (including the nav-security mock)
|
||||
# that only return the minimal {sub, iss, iat, exp} set.
|
||||
# Rule: if sub looks like an email address, use it directly.
|
||||
# Otherwise, construct a deterministic fallback so we never get NULL.
|
||||
raw_email = data.get("email")
|
||||
if not raw_email and sub:
|
||||
import re as _re
|
||||
if _re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", sub):
|
||||
raw_email = sub
|
||||
email_verified = True # if sub IS the email it's already verified
|
||||
else:
|
||||
# e.g. "12345" → "12345@google.local" so we can store it
|
||||
raw_email = f"{sub}@{provider or 'oauth'}.local"
|
||||
email_verified = False
|
||||
|
||||
# Derive display name when omitted
|
||||
raw_name = data.get("name") or data.get("display_name")
|
||||
if not raw_name and raw_email:
|
||||
raw_name = raw_email.split("@")[0]
|
||||
|
||||
# Standardize user info
|
||||
return {
|
||||
"provider_user_id": data.get("sub"),
|
||||
"email": data.get("email"),
|
||||
"provider_user_id": sub,
|
||||
"email": raw_email,
|
||||
"email_verified": email_verified,
|
||||
"name": data.get("name"),
|
||||
"name": raw_name,
|
||||
"first_name": data.get("given_name"),
|
||||
"last_name": data.get("family_name"),
|
||||
"picture": data.get("picture"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import jwt
|
||||
from flask import current_app, g
|
||||
|
||||
from gatehouse_app.models import User, OIDCClient
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -3,8 +3,8 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
from flask import current_app
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
|
||||
from gatehouse_app.utils.constants import OrganizationRole, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Session service."""
|
||||
from datetime import datetime, timezone
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class SessionService:
|
||||
Returns:
|
||||
Session object if found and active, None otherwise
|
||||
"""
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
return Session.query.filter_by(
|
||||
token=token,
|
||||
|
||||
@@ -253,12 +253,13 @@ class SSHCASigningService:
|
||||
certificate.fields.valid_after = now
|
||||
certificate.fields.valid_before = valid_before
|
||||
|
||||
# Set extensions
|
||||
# Set extensions — prefer policy-provided list, fall back to standard set
|
||||
extensions = signing_request.extensions
|
||||
if not extensions and self.config.get_bool('extensions_enabled'):
|
||||
extensions = self.config.get_list('extensions')
|
||||
|
||||
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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -111,5 +111,79 @@ def mfa_compliance_status():
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
@cli.command("configure_oauth")
|
||||
def configure_oauth():
|
||||
"""Interactively configure an OAuth provider at the application level.
|
||||
|
||||
Usage:
|
||||
python manage.py configure_oauth
|
||||
|
||||
Supported providers: google, github, microsoft
|
||||
"""
|
||||
import getpass
|
||||
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
SUPPORTED = ["google", "github", "microsoft"]
|
||||
|
||||
print("=" * 60)
|
||||
print("OAuth Provider Configuration")
|
||||
print("=" * 60)
|
||||
print(f"Supported providers: {', '.join(SUPPORTED)}")
|
||||
|
||||
provider = input("Provider [google/github/microsoft]: ").strip().lower()
|
||||
if provider not in SUPPORTED:
|
||||
print(f"❌ Unknown provider: {provider}")
|
||||
return
|
||||
|
||||
client_id = input("Client ID: ").strip()
|
||||
if not client_id:
|
||||
print("❌ client_id is required")
|
||||
return
|
||||
|
||||
client_secret = getpass.getpass("Client Secret (leave blank to keep existing): ").strip()
|
||||
|
||||
with app.app_context():
|
||||
config = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
|
||||
if config:
|
||||
config.client_id = client_id
|
||||
if client_secret:
|
||||
config.set_client_secret(client_secret)
|
||||
config.is_enabled = True
|
||||
db.session.commit()
|
||||
print(f"✅ Updated {provider} provider config.")
|
||||
else:
|
||||
config = ApplicationProviderConfig(
|
||||
provider_type=provider,
|
||||
client_id=client_id,
|
||||
is_enabled=True,
|
||||
)
|
||||
if client_secret:
|
||||
config.set_client_secret(client_secret)
|
||||
db.session.add(config)
|
||||
db.session.commit()
|
||||
print(f"✅ Created {provider} provider config.")
|
||||
|
||||
|
||||
@cli.command("list_oauth")
|
||||
def list_oauth():
|
||||
"""List all configured OAuth providers.
|
||||
|
||||
Usage:
|
||||
python manage.py list_oauth
|
||||
"""
|
||||
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
|
||||
|
||||
with app.app_context():
|
||||
configs = ApplicationProviderConfig.query.all()
|
||||
if not configs:
|
||||
print("No OAuth providers configured.")
|
||||
return
|
||||
print(f"{'Provider':<15} {'Client ID':<40} {'Enabled'}")
|
||||
print("-" * 65)
|
||||
for c in configs:
|
||||
print(f"{c.provider_type:<15} {c.client_id:<40} {c.is_enabled}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""add password reset and email verification token tables
|
||||
|
||||
Revision ID: 010_password_reset_email_verify
|
||||
Revises: 009_sync_auditaction_enum
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '010_password_reset_email_verify'
|
||||
down_revision = '009'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'password_reset_tokens',
|
||||
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('token', sa.String(128), nullable=False, unique=True),
|
||||
sa.Column('expires_at', sa.DateTime, nullable=False),
|
||||
sa.Column('used_at', sa.DateTime, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime, nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime, nullable=True),
|
||||
)
|
||||
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
|
||||
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
|
||||
|
||||
op.create_table(
|
||||
'email_verification_tokens',
|
||||
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('token', sa.String(128), nullable=False, unique=True),
|
||||
sa.Column('expires_at', sa.DateTime, nullable=False),
|
||||
sa.Column('used_at', sa.DateTime, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime, nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime, nullable=True),
|
||||
)
|
||||
op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id'])
|
||||
op.create_index('ix_email_verification_tokens_token', 'email_verification_tokens', ['token'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('email_verification_tokens')
|
||||
op.drop_table('password_reset_tokens')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add org_invite_tokens table
|
||||
|
||||
Revision ID: 011_org_invite_tokens
|
||||
Revises: 010_password_reset_email_verify
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '011_org_invite_tokens'
|
||||
down_revision = '010_password_reset_email_verify'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'org_invite_tokens',
|
||||
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
|
||||
sa.Column('organization_id', sa.String(36), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('invited_by_id', sa.String(36), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('email', sa.String(255), nullable=False),
|
||||
sa.Column('role', sa.String(64), nullable=False, server_default='member'),
|
||||
sa.Column('token', sa.String(128), nullable=False, unique=True),
|
||||
sa.Column('expires_at', sa.DateTime, nullable=False),
|
||||
sa.Column('accepted_at', sa.DateTime, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime, nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime, nullable=True),
|
||||
)
|
||||
op.create_index('ix_org_invite_tokens_organization_id', 'org_invite_tokens', ['organization_id'])
|
||||
op.create_index('ix_org_invite_tokens_email', 'org_invite_tokens', ['email'])
|
||||
op.create_index('ix_org_invite_tokens_token', 'org_invite_tokens', ['token'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('org_invite_tokens')
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add_department_cert_policies
|
||||
|
||||
Adds the department_cert_policies table which stores per-department
|
||||
SSH certificate issuance rules:
|
||||
- whether users may choose their own expiry
|
||||
- default and maximum expiry durations
|
||||
- allowed SSH certificate extensions
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "014_add_dept_cert_policy"
|
||||
down_revision = "013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"department_cert_policies",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("department_id", sa.String(36), sa.ForeignKey("departments.id"), nullable=False, unique=True),
|
||||
# Whether users are allowed to specify their own expiry (up to max)
|
||||
sa.Column("allow_user_expiry", sa.Boolean(), nullable=False, server_default="0"),
|
||||
# Default validity in hours (used when user doesn't specify, or not allowed to)
|
||||
sa.Column("default_expiry_hours", sa.Integer(), nullable=False, server_default="1"),
|
||||
# Hard cap on validity; admin cannot be exceeded
|
||||
sa.Column("max_expiry_hours", sa.Integer(), nullable=False, server_default="24"),
|
||||
# JSON list of extension names that are enabled for this department
|
||||
# e.g. ["permit-pty", "permit-agent-forwarding"]
|
||||
sa.Column("allowed_extensions", sa.JSON(), nullable=False, server_default='["permit-pty","permit-agent-forwarding","permit-X11-forwarding","permit-port-forwarding","permit-user-rc"]'),
|
||||
# Admin-defined custom extension names beyond the standard five
|
||||
sa.Column("custom_extensions", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("idx_dept_cert_policy_dept", "department_cert_policies", ["department_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("idx_dept_cert_policy_dept", "department_cert_policies")
|
||||
op.drop_table("department_cert_policies")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user