Feat(Fix): Key Timezone, Expiry, Depart Link

This commit is contained in:
2026-02-28 23:48:07 +05:45
parent 8fdc362216
commit e79c584c50
12 changed files with 1137 additions and 215 deletions
+40
View File
@@ -520,3 +520,43 @@ def remove_department_member(org_id, dept_id, user_id):
return api_response( return api_response(
message="Member removed successfully", message="Member removed successfully",
) )
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/principals", methods=["GET"])
@login_required
@full_access_required
def get_department_principals(org_id, dept_id):
"""Get all principals linked to a department."""
org = OrganizationService.get_organization_by_id(org_id)
if not org.is_member(g.current_user.id):
return api_response(
success=False,
message="You are not a member of this organization",
status=403,
error_type="AUTHORIZATION_ERROR",
)
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",
)
principals = dept.get_principals(active_only=True)
return api_response(
data={
"principals": [p.to_dict() for p in principals],
"count": len(principals),
},
message="Principals retrieved successfully",
)
+376 -1
View File
@@ -1,5 +1,5 @@
"""Organization endpoints.""" """Organization endpoints."""
from flask import g, request from flask import g, request, current_app
from marshmallow import ValidationError from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response from gatehouse_app.utils.response import api_response
@@ -13,6 +13,7 @@ from gatehouse_app.schemas.organization_schema import (
from gatehouse_app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.user_service import UserService from gatehouse_app.services.user_service import UserService
from gatehouse_app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.extensions import db
@api_v1_bp.route("/organizations", methods=["POST"]) @api_v1_bp.route("/organizations", methods=["POST"])
@login_required @login_required
@full_access_required @full_access_required
@@ -930,3 +931,377 @@ def get_my_audit_logs():
}, },
message="Activity retrieved", message="Activity retrieved",
) )
@api_v1_bp.route("/organizations/<org_id>/roles", methods=["GET"])
@login_required
def list_organization_roles(org_id):
"""List the available roles for an organization.
Returns the canonical set of OrganizationRole values together with every
current member assigned to each role.
Returns:
200: roles list with member counts
401: Not authenticated
404: Organization not found
"""
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
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, error_type="NOT_FOUND")
# Load all active members grouped by role
members = OrganizationMember.query.filter_by(organization_id=org_id, deleted_at=None).all()
by_role: dict = {r.value: [] for r in OrganizationRole}
for m in members:
role_key = m.role.value if hasattr(m.role, "value") else str(m.role)
if role_key in by_role:
by_role[role_key].append({
"user_id": m.user_id,
"email": m.user.email if m.user else None,
"full_name": m.user.full_name if m.user else None,
"joined_at": m.created_at.isoformat() if m.created_at else None,
})
roles = [
{
"role": r.value,
"member_count": len(by_role[r.value]),
"members": by_role[r.value],
}
for r in OrganizationRole
]
return api_response(data={"roles": roles, "organization_id": org_id}, message="Roles retrieved")
@api_v1_bp.route("/organizations/<org_id>/roles/<role_name>/members", methods=["POST"])
@login_required
@require_admin
def assign_role_to_member(org_id, role_name):
"""Assign a role to a user in the organization (admin/owner only).
This is a convenience endpoint equivalent to PATCH
/organizations/<org_id>/members/<user_id>/role but driven by role name.
Request body:
user_id UUID of the member to assign
Returns:
200: Role assigned
400: Invalid role / missing user_id
403: Not an admin/owner
404: Org or member not found
"""
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.extensions import db
try:
new_role = OrganizationRole(role_name.lower())
except ValueError:
valid = [r.value for r in OrganizationRole]
return api_response(success=False, message=f"Invalid role. Must be one of: {valid}", status=400, error_type="VALIDATION_ERROR")
data = request.get_json() or {}
target_user_id = data.get("user_id")
if not target_user_id:
return api_response(success=False, message="user_id is required", status=400, error_type="VALIDATION_ERROR")
membership = OrganizationMember.query.filter_by(
organization_id=org_id, user_id=target_user_id, deleted_at=None
).first()
if not membership:
return api_response(success=False, message="Member not found in this organization", status=404, error_type="NOT_FOUND")
membership.role = new_role
db.session.commit()
return api_response(
data={"user_id": target_user_id, "role": new_role.value},
message=f"Role updated to {new_role.value}",
)
@api_v1_bp.route("/organizations/<org_id>/roles/<role_name>/members/<user_id>", methods=["DELETE"])
@login_required
@require_admin
def remove_role_from_member(org_id, role_name, user_id):
"""Demote a member to GUEST (effectively removing a named role).
Removing a role downgrades the member to GUEST rather than removing them
from the organization entirely. Use the existing DELETE
/organizations/<org_id>/members/<user_id> endpoint to fully remove.
Returns:
200: Role removed (member demoted to GUEST)
400: Invalid role name
403: Not an admin/owner
404: Org or member not found
"""
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.extensions import db
try:
OrganizationRole(role_name.lower()) # validate the name
except ValueError:
valid = [r.value for r in OrganizationRole]
return api_response(success=False, message=f"Invalid role. Must be one of: {valid}", status=400, error_type="VALIDATION_ERROR")
membership = OrganizationMember.query.filter_by(
organization_id=org_id, user_id=user_id, deleted_at=None
).first()
if not membership:
return api_response(success=False, message="Member not found in this organization", status=404, error_type="NOT_FOUND")
membership.role = OrganizationRole.GUEST
db.session.commit()
return api_response(
data={"user_id": user_id, "role": OrganizationRole.GUEST.value},
message="Role removed; member demoted to GUEST",
)
@api_v1_bp.route("/organizations/<org_id>/cas", methods=["GET"])
@login_required
@require_admin
def list_org_cas(org_id):
"""List all Certificate Authorities for an organization.
Returns:
200: List of CAs (private_key excluded)
403: Not admin/owner
404: Org not found
"""
from gatehouse_app.models.ca import CA
from gatehouse_app.models.organization import 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, error_type="NOT_FOUND")
cas = CA.query.filter_by(organization_id=org_id, deleted_at=None).all()
return api_response(
data={"cas": [ca.to_dict() for ca in cas], "count": len(cas)},
message="CAs retrieved",
)
@api_v1_bp.route("/organizations/<org_id>/cas/<ca_id>", methods=["PATCH"])
@login_required
@require_admin
def update_org_ca(org_id, ca_id):
"""Update CA configuration (validity hours).
Request body:
default_cert_validity_hours: Default validity in hours (optional)
max_cert_validity_hours: Maximum validity in hours (optional)
Returns:
200: CA updated successfully
400: Validation error
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 marshmallow import Schema, fields, validate, ValidationError
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, error_type="NOT_FOUND")
ca = CA.query.filter_by(id=ca_id, organization_id=org_id, deleted_at=None).first()
if not ca:
return api_response(success=False, message="CA not found", status=404, error_type="NOT_FOUND")
try:
class CAUpdateSchema(Schema):
default_cert_validity_hours = fields.Int(
validate=validate.Range(min=1),
required=False
)
max_cert_validity_hours = fields.Int(
validate=validate.Range(min=1),
required=False
)
schema = CAUpdateSchema()
data = schema.load(request.json or {})
# Validate that max >= default if both are provided
default_hours = data.get('default_cert_validity_hours', ca.default_cert_validity_hours)
max_hours = data.get('max_cert_validity_hours', ca.max_cert_validity_hours)
if default_hours > max_hours:
return api_response(
success=False,
message="Default validity must be less than or equal to maximum validity",
status=400,
error_type="VALIDATION_ERROR",
)
# Update fields
if 'default_cert_validity_hours' in data:
ca.default_cert_validity_hours = data['default_cert_validity_hours']
if 'max_cert_validity_hours' in data:
ca.max_cert_validity_hours = data['max_cert_validity_hours']
db.session.commit()
return api_response(
data={"ca": ca.to_dict()},
message="CA updated successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except Exception as e:
db.session.rollback()
return api_response(
success=False,
message="Failed to update CA",
status=500,
error_type="SERVER_ERROR",
)
@api_v1_bp.route("/organizations/<org_id>/cas", methods=["POST"])
@login_required
@require_admin
def create_org_ca(org_id):
"""Create a new Certificate Authority for an organization.
Request body:
name: CA display name (required)
description: Optional description
key_type: "ed25519" (default), "rsa", or "ecdsa"
default_cert_validity_hours: Default cert validity in hours (optional)
max_cert_validity_hours: Max cert validity in hours (optional)
Returns:
201: CA created successfully
400: Validation error or name already taken
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.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
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, error_type="NOT_FOUND")
class CreateCASchema(Schema):
name = ma_fields.Str(required=True, validate=validate.Length(min=1, max=255))
description = ma_fields.Str(load_default=None, allow_none=True)
ca_type = ma_fields.Str(load_default="user", validate=validate.OneOf(["user", "host"]))
key_type = ma_fields.Str(load_default="ed25519", validate=validate.OneOf(["ed25519", "rsa", "ecdsa"]))
default_cert_validity_hours = ma_fields.Int(load_default=8, validate=validate.Range(min=1))
max_cert_validity_hours = ma_fields.Int(load_default=720, validate=validate.Range(min=1))
try:
schema = CreateCASchema()
data = schema.load(request.get_json() or {})
# Check name uniqueness within org
existing = CA.query.filter_by(
organization_id=org_id, name=data["name"], deleted_at=None
).first()
if existing:
return api_response(
success=False,
message="A CA with that name already exists in this organization",
status=400,
error_type="DUPLICATE_NAME",
)
# Enforce one CA per type per org
from gatehouse_app.models.ca import CaType
ca_type_val = data["ca_type"]
existing_type = CA.query.filter_by(
organization_id=org_id, deleted_at=None
).filter(CA.ca_type == CaType(ca_type_val)).first()
if existing_type:
type_label = "User" if ca_type_val == "user" else "Host"
return api_response(
success=False,
message=f"A {type_label} CA already exists for this organization. "
f"You can only have one {type_label} CA per organization.",
status=400,
error_type="DUPLICATE_CA_TYPE",
)
# Validate cross-field
if data["default_cert_validity_hours"] > data["max_cert_validity_hours"]:
return api_response(
success=False,
message="Default validity must be less than or equal to maximum validity",
status=400,
error_type="VALIDATION_ERROR",
)
# Generate key pair
key_type = data["key_type"]
if key_type == "ed25519":
private_key_obj = Ed25519PrivateKey.generate()
elif key_type == "rsa":
private_key_obj = RsaPrivateKey.generate(4096)
else: # ecdsa
private_key_obj = EcdsaPrivateKey.generate()
private_key_pem = private_key_obj.to_string()
public_key_str = private_key_obj.public_key.to_string()
fingerprint = compute_ssh_fingerprint(public_key_str)
ca = CA(
organization_id=org_id,
name=data["name"],
description=data["description"],
ca_type=CaType(ca_type_val),
key_type=KeyType(key_type),
private_key=private_key_pem,
public_key=public_key_str,
fingerprint=fingerprint,
default_cert_validity_hours=data["default_cert_validity_hours"],
max_cert_validity_hours=data["max_cert_validity_hours"],
is_active=True,
)
db.session.add(ca)
db.session.commit()
return api_response(
data={"ca": ca.to_dict()},
message="CA created successfully",
status=201,
)
except MaValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except Exception as e:
db.session.rollback()
current_app.logger.exception("Failed to create CA")
return api_response(
success=False,
message="Failed to create CA",
status=500,
error_type="SERVER_ERROR",
)
+27 -2
View File
@@ -8,6 +8,7 @@ from gatehouse_app.utils.decorators import login_required, require_admin, full_a
from gatehouse_app.models import Principal, PrincipalMembership, Department, DepartmentPrincipal from gatehouse_app.models import Principal, PrincipalMembership, Department, DepartmentPrincipal
from gatehouse_app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.user_service import UserService from gatehouse_app.services.user_service import UserService
from gatehouse_app.exceptions import OrganizationNotFoundError
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
@@ -614,7 +615,10 @@ def link_principal_to_department(org_id, principal_id, dept_id):
404: Organization, principal, or department not found 404: Organization, principal, or department not found
409: Already linked 409: Already linked
""" """
try:
org = OrganizationService.get_organization_by_id(org_id) org = OrganizationService.get_organization_by_id(org_id)
except OrganizationNotFoundError:
return api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND")
principal = Principal.query.filter_by( principal = Principal.query.filter_by(
id=principal_id, id=principal_id,
@@ -644,7 +648,6 @@ def link_principal_to_department(org_id, principal_id, dept_id):
error_type="NOT_FOUND", error_type="NOT_FOUND",
) )
# Check if already linked
existing = DepartmentPrincipal.query.filter_by( existing = DepartmentPrincipal.query.filter_by(
department_id=dept_id, department_id=dept_id,
principal_id=principal_id, principal_id=principal_id,
@@ -659,13 +662,35 @@ def link_principal_to_department(org_id, principal_id, dept_id):
error_type="CONFLICT", error_type="CONFLICT",
) )
# Create link soft_deleted = DepartmentPrincipal.query.filter(
DepartmentPrincipal.department_id == dept_id,
DepartmentPrincipal.principal_id == principal_id,
DepartmentPrincipal.deleted_at != None, # noqa: E711
).first()
try:
if soft_deleted:
soft_deleted.deleted_at = None
else:
link = DepartmentPrincipal( link = DepartmentPrincipal(
department_id=dept_id, department_id=dept_id,
principal_id=principal_id, principal_id=principal_id,
) )
db.session.add(link) db.session.add(link)
db.session.commit() db.session.commit()
except Exception as e:
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",
status=500,
error_type="SERVER_ERROR",
)
return api_response( return api_response(
message="Principal linked to department successfully", message="Principal linked to department successfully",
+195 -111
View File
@@ -16,6 +16,7 @@ from gatehouse_app.exceptions import (
from gatehouse_app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.models import AuditLog from gatehouse_app.models import AuditLog
from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.response import api_response
ssh_bp = Blueprint('ssh', __name__, url_prefix='/ssh') ssh_bp = Blueprint('ssh', __name__, url_prefix='/ssh')
ssh_key_service = SSHKeyService() ssh_key_service = SSHKeyService()
@@ -112,7 +113,7 @@ def _get_or_create_system_ca():
return None return None
def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=None): def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=None, cert_type_str='user'):
"""Save a signed certificate to the ssh_certificates table. """Save a signed certificate to the ssh_certificates table.
Args: Args:
@@ -121,6 +122,7 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
ca: CA model instance (may be None — cert still returned but not persisted) ca: CA model instance (may be None — cert still returned but not persisted)
signing_response: SSHCertificateSigningResponse signing_response: SSHCertificateSigningResponse
request_ip: Client IP address request_ip: Client IP address
cert_type_str: 'user' or 'host' (from the sign request)
Returns: Returns:
SSHCertificate instance or None if persistence failed SSHCertificate instance or None if persistence failed
@@ -133,6 +135,11 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ca import CertType from gatehouse_app.models.ca import CertType
try:
resolved_cert_type = CertType(cert_type_str)
except ValueError:
resolved_cert_type = CertType.USER
cert_record = SSHCertificate( cert_record = SSHCertificate(
ca_id=ca.id, ca_id=ca.id,
user_id=user_id, user_id=user_id,
@@ -140,7 +147,7 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
certificate=signing_response.certificate, certificate=signing_response.certificate,
serial=signing_response.serial, serial=signing_response.serial,
key_id=str(ssh_key_id), key_id=str(ssh_key_id),
cert_type=CertType.USER, cert_type=resolved_cert_type,
principals=signing_response.principals, principals=signing_response.principals,
valid_after=signing_response.valid_after, valid_after=signing_response.valid_after,
valid_before=signing_response.valid_before, valid_before=signing_response.valid_before,
@@ -172,10 +179,13 @@ def list_ssh_keys():
user_id = g.current_user.id user_id = g.current_user.id
keys = ssh_key_service.get_user_ssh_keys(user_id) keys = ssh_key_service.get_user_ssh_keys(user_id)
return jsonify({ return api_response(
data={
'keys': [k.to_dict() for k in keys], 'keys': [k.to_dict() for k in keys],
'count': len(keys), 'count': len(keys),
}), 200 },
message="SSH keys retrieved successfully"
)
@ssh_bp.route('/keys', methods=['POST']) @ssh_bp.route('/keys', methods=['POST'])
@@ -186,13 +196,13 @@ def add_ssh_key():
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'error': 'No JSON data provided'}), 400 return api_response(success=False, message='No JSON data provided', status=400, error_type='BAD_REQUEST')
public_key = data.get('public_key') or data.get('key') public_key = data.get('public_key') or data.get('key')
description = data.get('description') description = data.get('description')
if not public_key: if not public_key:
return jsonify({'error': 'public_key is required'}), 400 return api_response(success=False, message='public_key is required', status=400, error_type='BAD_REQUEST')
try: try:
ssh_key = ssh_key_service.add_ssh_key( ssh_key = ssh_key_service.add_ssh_key(
@@ -201,7 +211,6 @@ def add_ssh_key():
description=description, description=description,
) )
# Audit log
AuditLog.log( AuditLog.log(
action=AuditAction.SSH_KEY_ADDED, action=AuditAction.SSH_KEY_ADDED,
user_id=user_id, user_id=user_id,
@@ -210,16 +219,16 @@ def add_ssh_key():
ip_address=request.remote_addr, ip_address=request.remote_addr,
) )
return jsonify(ssh_key.to_dict()), 201 return api_response(success=True, message='SSH key added', data=ssh_key.to_dict(), status=201)
except SSHKeyAlreadyExistsError as e: except SSHKeyAlreadyExistsError as e:
return jsonify({'error': e.message, 'code': 'SSH_KEY_ALREADY_EXISTS'}), 409 return api_response(success=False, message=e.message, status=409, error_type='SSH_KEY_ALREADY_EXISTS')
except IntegrityError: except IntegrityError:
return jsonify({'error': 'SSH key already exists', 'code': 'SSH_KEY_ALREADY_EXISTS'}), 409 return api_response(success=False, message='SSH key already exists', status=409, error_type='SSH_KEY_ALREADY_EXISTS')
except SSHKeyError as e: except SSHKeyError as e:
return jsonify({'error': str(e)}), 400 return api_response(success=False, message=str(e), status=400, error_type='SSH_KEY_ERROR')
except ValidationError as e: except ValidationError as e:
return jsonify({'error': str(e)}), 400 return api_response(success=False, message=str(e), status=400, error_type='VALIDATION_ERROR')
@ssh_bp.route('/keys/<key_id>', methods=['GET']) @ssh_bp.route('/keys/<key_id>', methods=['GET'])
@@ -231,14 +240,13 @@ def get_ssh_key(key_id):
try: try:
ssh_key = ssh_key_service.get_ssh_key(key_id) ssh_key = ssh_key_service.get_ssh_key(key_id)
# Check ownership
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
return jsonify({'error': 'Forbidden'}), 403 return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
return jsonify(ssh_key.to_dict()), 200 return api_response(success=True, message='SSH key retrieved', data=ssh_key.to_dict(), status=200)
except SSHKeyNotFoundError: except SSHKeyNotFoundError:
return jsonify({'error': 'SSH key not found'}), 404 return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
@ssh_bp.route('/keys/<key_id>', methods=['DELETE']) @ssh_bp.route('/keys/<key_id>', methods=['DELETE'])
@@ -250,13 +258,11 @@ def delete_ssh_key(key_id):
try: try:
ssh_key = ssh_key_service.get_ssh_key(key_id) ssh_key = ssh_key_service.get_ssh_key(key_id)
# Check ownership
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
return jsonify({'error': 'Forbidden'}), 403 return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
ssh_key_service.delete_ssh_key(key_id) ssh_key_service.delete_ssh_key(key_id)
# Audit log
AuditLog.log( AuditLog.log(
action=AuditAction.SSH_KEY_DELETED, action=AuditAction.SSH_KEY_DELETED,
user_id=user_id, user_id=user_id,
@@ -265,10 +271,10 @@ def delete_ssh_key(key_id):
ip_address=request.remote_addr, ip_address=request.remote_addr,
) )
return jsonify({'status': 'deleted'}), 200 return api_response(success=True, message='SSH key deleted', data={'status': 'deleted'}, status=200)
except SSHKeyNotFoundError: except SSHKeyNotFoundError:
return jsonify({'error': 'SSH key not found'}), 404 return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
@ssh_bp.route('/keys/<key_id>/verify', methods=['GET', 'POST']) @ssh_bp.route('/keys/<key_id>/verify', methods=['GET', 'POST'])
@@ -280,33 +286,30 @@ def verify_ssh_key(key_id):
try: try:
ssh_key = ssh_key_service.get_ssh_key(key_id) ssh_key = ssh_key_service.get_ssh_key(key_id)
# Check ownership
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
return jsonify({'error': 'Forbidden'}), 403 return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
# Handle GET request - return challenge # GET — return a fresh challenge
if request.method == 'GET': if request.method == 'GET':
challenge = ssh_key_service.generate_verification_challenge(key_id) challenge = ssh_key_service.generate_verification_challenge(key_id)
return jsonify({ return api_response(success=True, message='Challenge generated', data={
'challenge_text': challenge, 'challenge_text': challenge,
'validationText': challenge, # Backwards compatibility 'validationText': challenge,
'key_id': key_id, 'key_id': key_id,
}), 200 }, status=200)
# Handle POST request - verify signature # POST — verify signature or generate challenge
data = request.get_json() or {} data = request.get_json() or {}
action = data.get('action', 'verify_signature') action = data.get('action', 'verify_signature')
if action == 'verify_signature': if action == 'verify_signature':
# Verify signature
signature = data.get('signature') signature = data.get('signature')
if not signature: if not signature:
return jsonify({'error': 'signature is required'}), 400 return api_response(success=False, message='signature is required', status=400, error_type='BAD_REQUEST')
try: try:
verified = ssh_key_service.verify_ssh_key_ownership(key_id, signature) verified = ssh_key_service.verify_ssh_key_ownership(key_id, signature)
# Audit log
AuditLog.log( AuditLog.log(
action=AuditAction.SSH_KEY_VERIFIED, action=AuditAction.SSH_KEY_VERIFIED,
user_id=user_id, user_id=user_id,
@@ -316,7 +319,7 @@ def verify_ssh_key(key_id):
success=verified, success=verified,
) )
return jsonify({'verified': verified}), 200 return api_response(success=True, message='Verification complete', data={'verified': verified}, status=200)
except Exception as e: except Exception as e:
AuditLog.log( AuditLog.log(
@@ -328,18 +331,17 @@ def verify_ssh_key(key_id):
success=False, success=False,
error_message=str(e), error_message=str(e),
) )
return jsonify({'error': str(e)}), 400 return api_response(success=False, message=str(e), status=400, error_type='VERIFICATION_FAILED')
else: # generate_challenge else: # generate_challenge
# Generate verification challenge
challenge = ssh_key_service.generate_verification_challenge(key_id) challenge = ssh_key_service.generate_verification_challenge(key_id)
return jsonify({ return api_response(success=True, message='Challenge generated', data={
'challenge_text': challenge, 'challenge_text': challenge,
'challenge': challenge, # Both for compatibility 'challenge': challenge,
}), 200 }, status=200)
except SSHKeyNotFoundError: except SSHKeyNotFoundError:
return jsonify({'error': 'SSH key not found'}), 404 return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
@ssh_bp.route('/keys/<key_id>/update-description', methods=['PATCH']) @ssh_bp.route('/keys/<key_id>/update-description', methods=['PATCH'])
@@ -350,24 +352,20 @@ def update_ssh_key_description(key_id):
data = request.get_json() data = request.get_json()
if not data or 'description' not in data: if not data or 'description' not in data:
return jsonify({'error': 'description is required'}), 400 return api_response(success=False, message='description is required', status=400, error_type='BAD_REQUEST')
try: try:
ssh_key = ssh_key_service.get_ssh_key(key_id) ssh_key = ssh_key_service.get_ssh_key(key_id)
# Check ownership
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
return jsonify({'error': 'Forbidden'}), 403 return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
updated_key = ssh_key_service.update_ssh_key_description( updated_key = ssh_key_service.update_ssh_key_description(key_id, data['description'])
key_id,
data['description']
)
return jsonify(updated_key.to_dict()), 200 return api_response(success=True, message='Description updated', data=updated_key.to_dict(), status=200)
except SSHKeyNotFoundError: except SSHKeyNotFoundError:
return jsonify({'error': 'SSH key not found'}), 404 return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
@ssh_bp.route('/sign', methods=['POST']) @ssh_bp.route('/sign', methods=['POST'])
@@ -379,38 +377,97 @@ def sign_certificate():
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'error': 'No JSON data provided'}), 400 return api_response(success=False, message="No JSON data provided", status=400, error_type="BAD_REQUEST")
try: requested_principals = data.get('principals') or []
principals = data.get('principals', [])
cert_type = data.get('cert_type', 'user') cert_type = data.get('cert_type', 'user')
# Accept both 'key_id' and 'cert_id' (from CLI)
key_id = data.get('key_id') or data.get('cert_id') key_id = data.get('key_id') or data.get('cert_id')
expiry_hours = data.get('expiry_hours') expiry_hours = data.get('expiry_hours')
if not principals: # ── Resolve which principals the user is allowed to use ──────────────────
return jsonify({'error': 'principals is required'}), 400 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.utils.constants import OrganizationRole
# If key_id not specified, use first verified key allowed_principal_names = set()
memberships = OrganizationMember.query.filter_by(user_id=user_id).all()
for om in memberships:
org = om.organization
if not org or org.deleted_at is not None:
continue
role = om.role
if role in (OrganizationRole.ADMIN, OrganizationRole.OWNER):
# Admin/owner can use any principal in the org
for p in Principal.query.filter_by(organization_id=org.id, deleted_at=None).all():
allowed_principal_names.add(p.name)
else:
# Direct memberships
for pm in PrincipalMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
allowed_principal_names.add(pm.principal.name)
# Via department
for dm in DepartmentMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None:
for dp in DepartmentPrincipal.query.filter_by(department_id=dm.department_id, deleted_at=None).all():
if dp.principal and dp.principal.deleted_at is None:
allowed_principal_names.add(dp.principal.name)
# ── Determine final principals list ─────────────────────────────────────
if not requested_principals:
# Auto-resolve: use all principals the user is assigned to
principals = list(allowed_principal_names)
if not principals:
return api_response(
success=False,
message="You have no principals assigned. Ask an admin to add you to a principal.",
status=400,
error_type="NO_PRINCIPALS",
)
else:
# Validate each requested principal is within the user's allowed set
invalid = [p for p in requested_principals if p not in allowed_principal_names]
if invalid:
return api_response(
success=False,
message=f"You are not authorised to request principals: {', '.join(invalid)}",
status=403,
error_type="UNAUTHORIZED_PRINCIPALS",
)
principals = requested_principals
# ── Key resolution ────────────────────────────────────────────────────────
if not key_id: if not key_id:
verified_keys = ssh_key_service.get_user_verified_ssh_keys(user_id) verified_keys = ssh_key_service.get_user_verified_ssh_keys(user_id)
if not verified_keys: if not verified_keys:
return jsonify({'error': 'No verified SSH keys found'}), 400 return api_response(
success=False,
message="No verified SSH keys found. Verify a key before requesting a certificate.",
status=400,
error_type="NO_VERIFIED_KEYS",
)
key_id = verified_keys[0].id key_id = verified_keys[0].id
# Get the SSH key try:
ssh_key = ssh_key_service.get_ssh_key(key_id) ssh_key = ssh_key_service.get_ssh_key(key_id)
except SSHKeyNotFoundError:
return api_response(success=False, message="SSH key not found", status=404, error_type="NOT_FOUND")
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
return jsonify({'error': 'Forbidden'}), 403 return api_response(success=False, message="Forbidden", status=403, error_type="FORBIDDEN")
if not ssh_key.verified: if not ssh_key.verified:
return jsonify({'error': 'SSH key is not verified'}), 400 return api_response(
success=False,
message="SSH key is not verified. Verify it before requesting a certificate.",
status=400,
error_type="KEY_NOT_VERIFIED",
)
# Resolve which CA to use: org DB CA > config-file CA
db_ca = _get_org_ca_for_user(user) db_ca = _get_org_ca_for_user(user)
ca_private_key = db_ca.private_key if db_ca else None # None → signing service uses config ca_private_key = db_ca.private_key if db_ca else None
# Create signing request
signing_request = SSHCertificateSigningRequest( signing_request = SSHCertificateSigningRequest(
ssh_public_key=ssh_key.payload, ssh_public_key=ssh_key.payload,
principals=principals, principals=principals,
@@ -418,17 +475,39 @@ def sign_certificate():
key_id=key_id, key_id=key_id,
expiry_hours=int(expiry_hours) if expiry_hours else None, expiry_hours=int(expiry_hours) if expiry_hours else None,
) )
# Validate request
validation_errors = signing_request.validate() validation_errors = signing_request.validate()
if validation_errors: if validation_errors:
return jsonify({'errors': validation_errors}), 400 return api_response(
success=False,
message="Invalid signing request",
status=400,
error_type="VALIDATION_ERROR",
error_details={"errors": validation_errors},
)
# Sign the certificate (pass ca_private_key=None → service loads from config) try:
response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key) response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key)
except SSHCertificateError as e:
AuditLog.log(
action=AuditAction.SSH_CERT_FAILED,
user_id=user_id,
resource_type='SSHCertificate',
ip_address=request.remote_addr,
success=False,
error_message=str(e),
)
return api_response(success=False, message=str(e), status=400, error_type="SIGNING_FAILED")
except Exception as e:
AuditLog.log(
action=AuditAction.SSH_CERT_FAILED,
user_id=user_id,
resource_type='SSHCertificate',
ip_address=request.remote_addr,
success=False,
error_message=str(e),
)
return api_response(success=False, message="Certificate signing failed", status=500, error_type="SERVER_ERROR")
# Persist certificate to DB
# If user's org has no DB CA, use the system-config-ca record
ca_for_db = db_ca or _get_or_create_system_ca() ca_for_db = db_ca or _get_or_create_system_ca()
cert_record = _persist_certificate( cert_record = _persist_certificate(
user_id=user_id, user_id=user_id,
@@ -436,9 +515,9 @@ def sign_certificate():
ca=ca_for_db, ca=ca_for_db,
signing_response=response, signing_response=response,
request_ip=request.remote_addr, request_ip=request.remote_addr,
cert_type_str=cert_type,
) )
# Audit log
AuditLog.log( AuditLog.log(
action=AuditAction.SSH_CERT_ISSUED, action=AuditAction.SSH_CERT_ISSUED,
user_id=user_id, user_id=user_id,
@@ -458,30 +537,7 @@ def sign_certificate():
if cert_record: if cert_record:
result['cert_id'] = str(cert_record.id) result['cert_id'] = str(cert_record.id)
return jsonify(result), 201 return api_response(data=result, message="Certificate signed successfully", status=201)
except SSHKeyNotFoundError:
return jsonify({'error': 'SSH key not found'}), 404
except SSHCertificateError as e:
AuditLog.log(
action=AuditAction.SSH_CERT_FAILED,
user_id=user_id,
resource_type='SSHCertificate',
ip_address=request.remote_addr,
success=False,
error_message=str(e),
)
return jsonify({'error': str(e)}), 400
except Exception as e:
AuditLog.log(
action=AuditAction.SSH_CERT_FAILED,
user_id=user_id,
resource_type='SSHCertificate',
ip_address=request.remote_addr,
success=False,
error_message=str(e),
)
return jsonify({'error': 'Certificate signing failed: ' + str(e)}), 500
@ssh_bp.route('/certificates', methods=['GET']) @ssh_bp.route('/certificates', methods=['GET'])
@@ -498,12 +554,20 @@ def list_certificates():
.order_by(SSHCertificate.created_at.desc()) .order_by(SSHCertificate.created_at.desc())
.all() .all()
) )
return jsonify({ return api_response(
data={
'certificates': [c.to_dict() for c in certs], 'certificates': [c.to_dict() for c in certs],
'count': len(certs), 'count': len(certs),
}), 200 },
message="Certificates retrieved successfully"
)
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return api_response(
success=False,
message=str(e),
status=500,
error_type='INTERNAL_ERROR'
)
@ssh_bp.route('/certificates/<cert_id>', methods=['GET']) @ssh_bp.route('/certificates/<cert_id>', methods=['GET'])
@@ -516,15 +580,14 @@ def get_certificate(cert_id):
from gatehouse_app.models.ssh_certificate import SSHCertificate from gatehouse_app.models.ssh_certificate import SSHCertificate
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first() cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
if not cert: if not cert:
return jsonify({'error': 'Certificate not found'}), 404 return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
if cert.user_id != user_id: if cert.user_id != user_id:
return jsonify({'error': 'Forbidden'}), 403 return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
# Include full certificate text in single-fetch endpoint
data = cert.to_dict() data = cert.to_dict()
data['certificate'] = cert.certificate data['certificate'] = cert.certificate
return jsonify(data), 200 return api_response(success=True, message='Certificate retrieved', data=data, status=200)
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return api_response(success=False, message=str(e), status=500, error_type='INTERNAL_ERROR')
@ssh_bp.route('/certificates/<cert_id>/revoke', methods=['POST']) @ssh_bp.route('/certificates/<cert_id>/revoke', methods=['POST'])
@@ -540,11 +603,11 @@ def revoke_certificate(cert_id):
from gatehouse_app.models.ssh_certificate import SSHCertificate from gatehouse_app.models.ssh_certificate import SSHCertificate
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first() cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
if not cert: if not cert:
return jsonify({'error': 'Certificate not found'}), 404 return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
if cert.user_id != user_id: if cert.user_id != user_id:
return jsonify({'error': 'Forbidden'}), 403 return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
if cert.revoked: if cert.revoked:
return jsonify({'error': 'Certificate is already revoked'}), 409 return api_response(success=False, message='Certificate is already revoked', status=409, error_type='ALREADY_REVOKED')
cert.revoke(reason=reason) cert.revoke(reason=reason)
@@ -557,9 +620,14 @@ def revoke_certificate(cert_id):
description=f'Revoked: {reason}', description=f'Revoked: {reason}',
) )
return jsonify({'status': 'revoked', 'cert_id': cert_id, 'reason': reason}), 200 return api_response(
success=True,
message='Certificate revoked successfully',
data={'status': 'revoked', 'cert_id': cert_id, 'reason': reason},
status=200,
)
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return api_response(success=False, message=str(e), status=500, error_type='INTERNAL_ERROR')
@ssh_bp.route('/ca/public-key', methods=['GET']) @ssh_bp.route('/ca/public-key', methods=['GET'])
@@ -584,12 +652,15 @@ def get_ca_public_key():
# Try org CA first # Try org CA first
db_ca = _get_org_ca_for_user(user) db_ca = _get_org_ca_for_user(user)
if db_ca: if db_ca:
return jsonify({ return api_response(
data={
'public_key': db_ca.public_key, 'public_key': db_ca.public_key,
'fingerprint': db_ca.fingerprint, 'fingerprint': db_ca.fingerprint,
'ca_name': db_ca.name, 'ca_name': db_ca.name,
'source': 'db', 'source': 'db',
}), 200 },
message="CA public key retrieved successfully"
)
# Fall back to config-file CA # Fall back to config-file CA
try: try:
@@ -601,15 +672,28 @@ def get_ca_public_key():
with open(key_path) as f: with open(key_path) as f:
pub_key = f.read().strip() pub_key = f.read().strip()
from gatehouse_app.utils.crypto import compute_ssh_fingerprint from gatehouse_app.utils.crypto import compute_ssh_fingerprint
return jsonify({ return api_response(
data={
'public_key': pub_key, 'public_key': pub_key,
'fingerprint': compute_ssh_fingerprint(pub_key), 'fingerprint': compute_ssh_fingerprint(pub_key),
'ca_name': 'system-config-ca', 'ca_name': 'system-config-ca',
'source': 'config', 'source': 'config',
}), 200 },
message="CA public key retrieved successfully"
)
except Exception as e: except Exception as e:
return jsonify({'error': f'Could not load CA public key: {e}'}), 500 return api_response(
success=False,
message=f'Could not load CA public key: {e}',
status=500,
error_type='INTERNAL_ERROR'
)
return jsonify({'error': 'No CA configured for this organization'}), 404 return api_response(
success=False,
message='No CA configured for this organization',
status=404,
error_type='NOT_FOUND'
)
+231
View File
@@ -157,3 +157,234 @@ def get_my_organizations():
}, },
message="Organizations retrieved successfully", message="Organizations retrieved successfully",
) )
@api_v1_bp.route("/users/me/principals", methods=["GET"])
@login_required
@full_access_required
def get_my_principals():
"""Return all principals the current user can sign certificates for.
For each organization the user belongs to, returns:
- Their effective principals (direct membership + via department)
- Their role in that org (so the frontend can offer admin-mode selection)
- All principals in the org (admin/owner only — so they can pick any)
Returns:
200: {
orgs: [{
org_id, org_name, role,
my_principals: [{id, name, description}],
all_principals: [{id, name, description}] # populated for admin/owner only
}]
}
"""
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.utils.constants import OrganizationRole
user = g.current_user
user_id = user.id
# Get all org memberships
memberships = OrganizationMember.query.filter_by(
user_id=user_id,
).all()
orgs_result = []
for membership in memberships:
org = membership.organization
if not org or org.deleted_at is not None:
continue
role = membership.role
is_admin = role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
# Collect the user's effective principals for this org
effective_principal_ids = set()
# Direct memberships
direct = PrincipalMembership.query.filter_by(
user_id=user_id,
deleted_at=None,
).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)
# Via department
dept_memberships = DepartmentMembership.query.filter_by(
user_id=user_id,
deleted_at=None,
).all()
for dm in dept_memberships:
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None:
dept_principals = DepartmentPrincipal.query.filter_by(
department_id=dm.department_id,
deleted_at=None,
).all()
for dp in dept_principals:
if dp.principal and dp.principal.deleted_at is None:
effective_principal_ids.add(dp.principal_id)
# Fetch principal objects
my_principals = []
if effective_principal_ids:
my_p = Principal.query.filter(
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]
# For admins/owners: also return all principals in the org
all_principals = []
if is_admin:
all_p = Principal.query.filter_by(
organization_id=org.id,
deleted_at=None,
).all()
all_principals = [{"id": p.id, "name": p.name, "description": p.description} for p in all_p]
orgs_result.append({
"org_id": org.id,
"org_name": org.name,
"role": role.value if hasattr(role, "value") else role,
"is_admin": is_admin,
"my_principals": my_principals,
"all_principals": all_principals,
})
return api_response(
data={"orgs": orgs_result},
message="Principals retrieved successfully",
)
@api_v1_bp.route("/admin/users", methods=["GET"])
@login_required
def admin_list_users():
"""List all users the caller has admin rights to see.
The caller must be an OWNER or ADMIN of at least one organization.
Returns users that share an organization with the caller and where the
caller holds admin/owner role in that organization.
Query params:
q optional search string (matched against name/email)
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.extensions import db as _db
from sqlalchemy import or_
caller = g.current_user
# Find orgs where caller is admin/owner
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == caller.id,
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
OrganizationMember.deleted_at == None,
).all()
if not admin_memberships:
return api_response(
success=False,
message="Admin or owner role required",
status=403,
error_type="AUTHORIZATION_ERROR",
)
admin_org_ids = [m.organization_id for m in admin_memberships]
# Collect user IDs in those orgs
member_rows = OrganizationMember.query.filter(
OrganizationMember.organization_id.in_(admin_org_ids),
OrganizationMember.deleted_at == None,
).all()
visible_user_ids = list({row.user_id for row in member_rows})
# Optional search
q = request.args.get("q", "").strip()
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(200, max(1, int(request.args.get("per_page", 50))))
except ValueError:
page, per_page = 1, 50
query = _User.query.filter(
_User.id.in_(visible_user_ids),
_User.deleted_at == None,
)
if q:
like = f"%{q}%"
query = query.filter(or_(_User.email.ilike(like), _User.full_name.ilike(like)))
total = query.count()
users = query.order_by(_User.email).offset((page - 1) * per_page).limit(per_page).all()
member_lookup: dict = {}
for row in member_rows:
if row.user_id not in member_lookup:
member_lookup[row.user_id] = {
"organization_id": row.organization_id,
"role": row.role.value if hasattr(row.role, "value") else row.role,
}
users_data = []
for u in users:
d = u.to_dict()
m = member_lookup.get(u.id, {})
d["org_role"] = m.get("role", "member")
d["org_id"] = m.get("organization_id")
users_data.append(d)
return api_response(
data={
"users": users_data,
"count": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
},
message="Users retrieved successfully",
)
@api_v1_bp.route("/admin/users/<user_id>", methods=["GET"])
@login_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
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}
has_access = 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() is not None
if not has_access:
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
ssh_keys = SSHKey.query.filter_by(user_id=user_id, deleted_at=None).all()
return api_response(
data={
"user": target.to_dict(),
"ssh_keys": [k.to_dict() for k in ssh_keys],
},
message="User retrieved",
)
+2 -1
View File
@@ -30,7 +30,7 @@ from gatehouse_app.models.principal import (
PrincipalMembership, PrincipalMembership,
) )
from gatehouse_app.models.ssh_key import SSHKey from gatehouse_app.models.ssh_key import SSHKey
from gatehouse_app.models.ca import CA, KeyType, CertType from gatehouse_app.models.ca import CA, KeyType, CertType, CAPermission
from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.certificate_audit_log import CertificateAuditLog from gatehouse_app.models.certificate_audit_log import CertificateAuditLog
from gatehouse_app.models.password_reset_token import PasswordResetToken from gatehouse_app.models.password_reset_token import PasswordResetToken
@@ -66,6 +66,7 @@ __all__ = [
"CA", "CA",
"KeyType", "KeyType",
"CertType", "CertType",
"CAPermission",
"SSHCertificate", "SSHCertificate",
"CertificateStatus", "CertificateStatus",
"CertificateAuditLog", "CertificateAuditLog",
+4 -1
View File
@@ -82,7 +82,10 @@ class BaseModel(db.Model):
if column.name not in exclude: if column.name not in exclude:
value = getattr(self, column.name) value = getattr(self, column.name)
if isinstance(value, datetime): if isinstance(value, datetime):
result[column.name] = value.isoformat() if value.tzinfo is None:
result[column.name] = value.isoformat() + "Z"
else:
result[column.name] = value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
else: else:
result[column.name] = value result[column.name] = value
return result return result
+65
View File
@@ -20,6 +20,13 @@ class CertType(str, Enum):
HOST = "host" HOST = "host"
class CaType(str, Enum):
"""CA signing type — whether this CA signs user or host certificates."""
USER = "user"
HOST = "host"
class CA(BaseModel): class CA(BaseModel):
"""Certificate Authority (CA) model for SSH certificate signing. """Certificate Authority (CA) model for SSH certificate signing.
@@ -41,6 +48,13 @@ class CA(BaseModel):
name = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
# CA signing type: 'user' signs user certificates, 'host' signs host certificates
ca_type = db.Column(
db.Enum(CaType, values_callable=lambda x: [e.value for e in x]),
default=CaType.USER,
nullable=False,
)
# Key type (ED25519, RSA, ECDSA) # Key type (ED25519, RSA, ECDSA)
key_type = db.Column( key_type = db.Column(
db.Enum(KeyType, values_callable=lambda x: [e.value for e in x]), db.Enum(KeyType, values_callable=lambda x: [e.value for e in x]),
@@ -91,6 +105,11 @@ class CA(BaseModel):
back_populates="ca", back_populates="ca",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
permissions = db.relationship(
"CAPermission",
back_populates="ca",
cascade="all, delete-orphan",
)
__table_args__ = ( __table_args__ = (
db.UniqueConstraint( db.UniqueConstraint(
@@ -153,3 +172,49 @@ class CA(BaseModel):
self.rotated_at = datetime.utcnow() self.rotated_at = datetime.utcnow()
self.rotation_reason = reason self.rotation_reason = reason
self.save() self.save()
class CAPermission(BaseModel):
"""Per-user CA permission model.
Controls which users are allowed to sign certificates against a specific CA.
When a CA has any permission rows the signing endpoint enforces the list;
CAs with no rows are open to all org members (backwards-compatible default).
Permission values:
sign user may request certificate signing
admin user may sign AND manage the CA (rotate keys, delete, etc.)
"""
__tablename__ = "ca_permissions"
ca_id = db.Column(
db.String(36),
db.ForeignKey("cas.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
permission = db.Column(db.String(50), nullable=False, default="sign")
# Relationships
ca = db.relationship("CA", back_populates="permissions")
user = db.relationship("User", back_populates="ca_permissions")
__table_args__ = (
db.UniqueConstraint("ca_id", "user_id", name="uix_ca_permission"),
)
def __repr__(self):
return f"<CAPermission ca_id={self.ca_id} user_id={self.user_id} permission={self.permission}>"
def to_dict(self, exclude=None):
data = super().to_dict(exclude=exclude or [])
data["permission"] = self.permission
return data
+11 -5
View File
@@ -1,6 +1,6 @@
"""SSH Certificate model.""" """SSH Certificate model."""
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime, timezone
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.ca import CertType from gatehouse_app.models.ca import CertType
@@ -137,8 +137,10 @@ class SSHCertificate(BaseModel):
if self.revoked or self.status == CertificateStatus.REVOKED: if self.revoked or self.status == CertificateStatus.REVOKED:
return False return False
now = datetime.utcnow() now = datetime.now(timezone.utc)
return self.valid_after <= now <= self.valid_before valid_after = self.valid_after.replace(tzinfo=timezone.utc) if self.valid_after.tzinfo is None else self.valid_after
valid_before = self.valid_before.replace(tzinfo=timezone.utc) if self.valid_before.tzinfo is None else self.valid_before
return valid_after <= now <= valid_before
def is_expired(self): def is_expired(self):
"""Check if certificate has expired. """Check if certificate has expired.
@@ -146,7 +148,9 @@ class SSHCertificate(BaseModel):
Returns: Returns:
True if current time is past valid_before True if current time is past valid_before
""" """
return datetime.utcnow() > self.valid_before now = datetime.now(timezone.utc)
valid_before = self.valid_before.replace(tzinfo=timezone.utc) if self.valid_before.tzinfo is None else self.valid_before
return now > valid_before
def days_until_expiry(self): def days_until_expiry(self):
"""Get number of days until certificate expires. """Get number of days until certificate expires.
@@ -154,7 +158,9 @@ class SSHCertificate(BaseModel):
Returns: Returns:
Number of days remaining (negative if already expired) Number of days remaining (negative if already expired)
""" """
delta = self.valid_before - datetime.utcnow() now = datetime.now(timezone.utc)
valid_before = self.valid_before.replace(tzinfo=timezone.utc) if self.valid_before.tzinfo is None else self.valid_before
delta = valid_before - now
return delta.days + (1 if delta.seconds > 0 else 0) return delta.days + (1 if delta.seconds > 0 else 0)
def revoke(self, reason=None): def revoke(self, reason=None):
@@ -5,7 +5,7 @@ This service is a Gatehouse-integrated version of the secuird/ssh_ca.py logic.
""" """
import logging import logging
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from sshkey_tools.cert import SSHCertificate, CertificateFields from sshkey_tools.cert import SSHCertificate, CertificateFields
@@ -240,7 +240,7 @@ class SSHCASigningService:
) )
# Set validity period # Set validity period
now = datetime.utcnow() now = datetime.now(timezone.utc)
expiry_hours = signing_request.expiry_hours or self.config.get_int('cert_validity_hours') expiry_hours = signing_request.expiry_hours or self.config.get_int('cert_validity_hours')
valid_before = now + timedelta(hours=expiry_hours) valid_before = now + timedelta(hours=expiry_hours)
+42
View File
@@ -0,0 +1,42 @@
"""Add ca_type column to cas table (user/host).
Revision ID: 013
Revises: d34bfb72844e
Create Date: 2026-02-28 23:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '013'
down_revision = 'd34bfb72844e'
branch_labels = None
depends_on = None
def upgrade():
# Create the enum type first (PostgreSQL requires this)
ca_type_enum = sa.Enum('user', 'host', name='ca_type_enum')
ca_type_enum.create(op.get_bind(), checkfirst=True)
# Add ca_type column with a default of 'user' so existing CAs stay valid
op.add_column(
'cas',
sa.Column(
'ca_type',
ca_type_enum,
nullable=False,
server_default='user',
),
)
def downgrade():
op.drop_column('cas', 'ca_type')
# Drop the enum type (PostgreSQL only; SQLite ignores)
try:
op.execute("DROP TYPE IF EXISTS ca_type_enum")
except Exception:
pass
@@ -0,0 +1,50 @@
"""add_activation_fields_and_ca_permissions
Revision ID: d34bfb72844e
Revises: 012_ca_nullable_org
Create Date: 2026-02-28 18:06:47.328552
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd34bfb72844e'
down_revision = '012_ca_nullable_org'
branch_labels = None
depends_on = None
def upgrade():
# Create ca_permissions table
op.create_table(
'ca_permissions',
sa.Column('ca_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('permission', sa.String(length=50), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['ca_id'], ['cas.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('ca_id', 'user_id', name='uix_ca_permission'),
)
op.create_index('ix_ca_permissions_ca_id', 'ca_permissions', ['ca_id'], unique=False)
op.create_index('ix_ca_permissions_user_id', 'ca_permissions', ['user_id'], unique=False)
# Add activation columns to users
op.add_column('users', sa.Column('activated', sa.Boolean(), nullable=False,
server_default=sa.text('true')))
op.add_column('users', sa.Column('activation_key', sa.String(length=128), nullable=True))
op.create_index('ix_users_activation_key', 'users', ['activation_key'], unique=True)
def downgrade():
op.drop_index('ix_users_activation_key', table_name='users')
op.drop_column('users', 'activation_key')
op.drop_column('users', 'activated')
op.drop_index('ix_ca_permissions_user_id', table_name='ca_permissions')
op.drop_index('ix_ca_permissions_ca_id', table_name='ca_permissions')
op.drop_table('ca_permissions')