Feat: Implemented SUDO Department & API Key, CA Serial
This commit is contained in:
@@ -5,6 +5,7 @@ from flask import Blueprint
|
|||||||
api_v1_bp = Blueprint("api_v1", __name__)
|
api_v1_bp = Blueprint("api_v1", __name__)
|
||||||
|
|
||||||
# Import route modules to register them
|
# Import route modules to register them
|
||||||
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier
|
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, sudo
|
||||||
|
|
||||||
api_v1_bp.register_blueprint(ssh.ssh_bp)
|
api_v1_bp.register_blueprint(ssh.ssh_bp)
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ class DepartmentCreateSchema(Schema):
|
|||||||
"""Schema for creating a department."""
|
"""Schema for creating a department."""
|
||||||
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
|
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
|
||||||
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
can_sudo = fields.Bool(allow_none=True, load_default=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class DepartmentUpdateSchema(Schema):
|
class DepartmentUpdateSchema(Schema):
|
||||||
"""Schema for updating a department."""
|
"""Schema for updating a department."""
|
||||||
name = fields.Str(validate=validate.Length(min=1, max=255))
|
name = fields.Str(validate=validate.Length(min=1, max=255))
|
||||||
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
can_sudo = fields.Bool(allow_none=True)
|
||||||
|
|
||||||
|
|
||||||
class AddDepartmentMemberSchema(Schema):
|
class AddDepartmentMemberSchema(Schema):
|
||||||
@@ -119,6 +122,7 @@ def create_department(org_id):
|
|||||||
organization_id=org_id,
|
organization_id=org_id,
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
|
can_sudo=data.get("can_sudo", False),
|
||||||
)
|
)
|
||||||
db.session.add(dept)
|
db.session.add(dept)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Organization routes package."""
|
"""Organization routes package."""
|
||||||
from gatehouse_app.api.v1.organizations import core, members, invites, clients, cas, audit, roles
|
from gatehouse_app.api.v1.organizations import core, members, invites, clients, cas, audit, roles, api_keys
|
||||||
|
|
||||||
__all__ = ["core", "members", "invites", "clients", "cas", "audit", "roles"]
|
__all__ = ["core", "members", "invites", "clients", "cas", "audit", "roles", "api_keys"]
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Organization API Key management endpoints."""
|
||||||
|
from flask import g, request
|
||||||
|
from marshmallow import Schema, fields, validate, ValidationError
|
||||||
|
|
||||||
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
|
from gatehouse_app.utils.response import api_response
|
||||||
|
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||||
|
from gatehouse_app.models.organization import OrganizationApiKey
|
||||||
|
from gatehouse_app.services.organization_service import OrganizationService
|
||||||
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyCreateSchema(Schema):
|
||||||
|
"""Schema for creating an API key."""
|
||||||
|
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
|
||||||
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyUpdateSchema(Schema):
|
||||||
|
"""Schema for updating an API key."""
|
||||||
|
name = fields.Str(validate=validate.Length(min=1, max=255))
|
||||||
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/api-keys", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def list_api_keys(org_id):
|
||||||
|
"""
|
||||||
|
List all API keys for an organization.
|
||||||
|
|
||||||
|
Only accessible by organization admins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of API keys (without key values)
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
# Check if user is an admin
|
||||||
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||||
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
membership = OrganizationMember.query.filter_by(
|
||||||
|
user_id=g.current_user.id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You do not have permission to manage API keys",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_keys = OrganizationApiKey.query.filter_by(
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"api_keys": [k.to_dict() for k in api_keys],
|
||||||
|
"count": len(api_keys),
|
||||||
|
},
|
||||||
|
message="API keys retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/api-keys", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def create_api_key(org_id):
|
||||||
|
"""
|
||||||
|
Create a new API key for an organization.
|
||||||
|
|
||||||
|
Only accessible by organization admins.
|
||||||
|
The plain text key is returned only on creation and should be stored securely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
name: API key name (required)
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
201: API key created successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
# Check if user is an admin
|
||||||
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||||
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
membership = OrganizationMember.query.filter_by(
|
||||||
|
user_id=g.current_user.id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You do not have permission to create API keys",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = ApiKeyCreateSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Create the API key
|
||||||
|
api_key, plain_key = OrganizationApiKey.create_key(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the key data with the plain text key (only on creation)
|
||||||
|
key_dict = api_key.to_dict()
|
||||||
|
key_dict["key"] = plain_key # Include plain text only on creation
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"api_key": key_dict},
|
||||||
|
message="API key created successfully. Store the key value securely - it cannot be retrieved later.",
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/api-keys/<key_id>", methods=["PATCH"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def update_api_key(org_id, key_id):
|
||||||
|
"""
|
||||||
|
Update an API key.
|
||||||
|
|
||||||
|
Only accessible by organization admins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
key_id: API Key ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
name: New name (optional)
|
||||||
|
description: New description (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: API key updated successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization or API key not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
# Check if user is an admin
|
||||||
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||||
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
membership = OrganizationMember.query.filter_by(
|
||||||
|
user_id=g.current_user.id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You do not have permission to update API keys",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = OrganizationApiKey.query.filter_by(
|
||||||
|
id=key_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="API key not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = ApiKeyUpdateSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if "name" in data:
|
||||||
|
api_key.name = data["name"]
|
||||||
|
if "description" in data:
|
||||||
|
api_key.description = data["description"]
|
||||||
|
|
||||||
|
api_key.save()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"api_key": api_key.to_dict()},
|
||||||
|
message="API key updated successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/api-keys/<key_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def delete_api_key(org_id, key_id):
|
||||||
|
"""
|
||||||
|
Delete/revoke an API key.
|
||||||
|
|
||||||
|
Only accessible by organization admins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
key_id: API Key ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: API key deleted successfully
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization or API key not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
# Check if user is an admin
|
||||||
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||||
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
membership = OrganizationMember.query.filter_by(
|
||||||
|
user_id=g.current_user.id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You do not have permission to delete API keys",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = OrganizationApiKey.query.filter_by(
|
||||||
|
id=key_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="API key not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete the API key
|
||||||
|
api_key.delete(soft=True)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="API key deleted successfully",
|
||||||
|
)
|
||||||
@@ -173,3 +173,101 @@ def get_my_audit_logs():
|
|||||||
},
|
},
|
||||||
message="Activity retrieved",
|
message="Activity retrieved",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/certificates/audit", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def get_certificate_audit_logs(org_id):
|
||||||
|
"""
|
||||||
|
Get certificate issuance audit logs for an organization.
|
||||||
|
|
||||||
|
Only accessible by organization admins.
|
||||||
|
Returns certificate serial IDs, user IDs, and issuance timestamps for compliance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of certificate audit logs
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization not found
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.ssh_ca.certificate_audit_log import CertificateAuditLog
|
||||||
|
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||||
|
from gatehouse_app.models.user import User
|
||||||
|
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
page = max(1, int(request.args.get("page", 1)))
|
||||||
|
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||||
|
action_filter = request.args.get("action", "signed") # Default to signed certificates
|
||||||
|
|
||||||
|
# Get all CAs for this organization
|
||||||
|
from gatehouse_app.models.ssh_ca import CA
|
||||||
|
org_cas = CA.query.filter_by(organization_id=org_id, deleted_at=None).all()
|
||||||
|
org_ca_ids = [ca.id for ca in org_cas]
|
||||||
|
|
||||||
|
if not org_ca_ids:
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"audit_logs": [],
|
||||||
|
"count": 0,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"pages": 0,
|
||||||
|
},
|
||||||
|
message="No certificate audit logs found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query certificate audit logs for certificates issued by org's CAs
|
||||||
|
query = CertificateAuditLog.query.join(
|
||||||
|
SSHCertificate,
|
||||||
|
CertificateAuditLog.certificate_id == SSHCertificate.id
|
||||||
|
).filter(
|
||||||
|
SSHCertificate.ca_id.in_(org_ca_ids),
|
||||||
|
CertificateAuditLog.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if action_filter:
|
||||||
|
query = query.filter(CertificateAuditLog.action == action_filter)
|
||||||
|
|
||||||
|
query = query.order_by(CertificateAuditLog.created_at.desc())
|
||||||
|
total = query.count()
|
||||||
|
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||||
|
|
||||||
|
# Build response data with certificate details
|
||||||
|
audit_data = []
|
||||||
|
for log in logs:
|
||||||
|
cert = log.certificate
|
||||||
|
user = log.user
|
||||||
|
audit_data.append({
|
||||||
|
"id": log.id,
|
||||||
|
"action": log.action,
|
||||||
|
"certificate_serial": cert.serial,
|
||||||
|
"key_id": cert.key_id,
|
||||||
|
"principals": cert.principals,
|
||||||
|
"user_id": user.id if user else cert.user_id,
|
||||||
|
"user_email": user.email if user else None,
|
||||||
|
"issued_at": cert.created_at.isoformat() if cert.created_at else None,
|
||||||
|
"valid_after": cert.valid_after.isoformat() if cert.valid_after else None,
|
||||||
|
"valid_before": cert.valid_before.isoformat() if cert.valid_before else None,
|
||||||
|
"ip_address": log.ip_address,
|
||||||
|
"user_agent": log.user_agent,
|
||||||
|
"message": log.message,
|
||||||
|
"success": log.success,
|
||||||
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"audit_logs": audit_data,
|
||||||
|
"count": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"pages": (total + per_page - 1) // per_page,
|
||||||
|
},
|
||||||
|
message="Certificate audit logs retrieved successfully",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Sudoer check and sudo-related endpoints."""
|
||||||
|
from flask import request
|
||||||
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
|
from gatehouse_app.utils.response import api_response
|
||||||
|
from gatehouse_app.models.organization import OrganizationApiKey
|
||||||
|
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||||
|
from gatehouse_app.models.organization import Department, DepartmentMembership
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/sudo/check", methods=["POST"])
|
||||||
|
def check_sudoer():
|
||||||
|
"""
|
||||||
|
Check if a user with a given certificate can sudo.
|
||||||
|
|
||||||
|
This endpoint validates an API key for an organization, retrieves the certificate
|
||||||
|
by serial ID, finds the user and their departments, and checks if any of their
|
||||||
|
departments have sudo capability.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
api_key: Organization API key (required)
|
||||||
|
certificate_serial: Certificate serial ID (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Sudoer status returned
|
||||||
|
400: Invalid request body
|
||||||
|
401: Invalid API key
|
||||||
|
403: Certificate not found or user not found
|
||||||
|
404: Organization or certificate not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Request body is required",
|
||||||
|
status=400,
|
||||||
|
error_type="INVALID_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = data.get("api_key")
|
||||||
|
certificate_serial = data.get("certificate_serial")
|
||||||
|
|
||||||
|
if not api_key or certificate_serial is None:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="api_key and certificate_serial are required",
|
||||||
|
status=400,
|
||||||
|
error_type="MISSING_REQUIRED_FIELDS",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the certificate by serial
|
||||||
|
certificate = SSHCertificate.query.filter_by(
|
||||||
|
serial=certificate_serial,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not certificate:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Certificate not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the CA and organization
|
||||||
|
ca = certificate.ca
|
||||||
|
if not ca:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Certificate CA not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
org_id = ca.organization_id
|
||||||
|
|
||||||
|
# Verify the API key for this organization
|
||||||
|
org_api_key = OrganizationApiKey.verify_key(org_id, api_key)
|
||||||
|
|
||||||
|
if not org_api_key:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Invalid API key for organization",
|
||||||
|
status=401,
|
||||||
|
error_type="UNAUTHORIZED",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the user from the certificate
|
||||||
|
user = certificate.user
|
||||||
|
if not user:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Certificate user not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all departments the user belongs to
|
||||||
|
user_departments = DepartmentMembership.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
deleted_at=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Check if any of the user's departments have sudo capability
|
||||||
|
can_sudo = False
|
||||||
|
sudoer_departments = []
|
||||||
|
|
||||||
|
for dept_membership in user_departments:
|
||||||
|
dept = dept_membership.department
|
||||||
|
if dept and dept.can_sudo and dept.deleted_at is None:
|
||||||
|
can_sudo = True
|
||||||
|
sudoer_departments.append({
|
||||||
|
"id": dept.id,
|
||||||
|
"name": dept.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"can_sudo": can_sudo,
|
||||||
|
"user_id": user.id,
|
||||||
|
"user_email": user.email,
|
||||||
|
"certificate_serial": certificate.serial,
|
||||||
|
"sudoer_departments": sudoer_departments,
|
||||||
|
"all_departments_count": len(user_departments),
|
||||||
|
},
|
||||||
|
message="Sudoer status retrieved successfully",
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=f"An error occurred: {str(e)}",
|
||||||
|
status=500,
|
||||||
|
error_type="INTERNAL_ERROR",
|
||||||
|
)
|
||||||
@@ -12,6 +12,7 @@ from gatehouse_app.models.organization.department_cert_policy import (
|
|||||||
)
|
)
|
||||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||||
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
||||||
|
from gatehouse_app.models.organization.organization_api_key import OrganizationApiKey
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Organization",
|
"Organization",
|
||||||
@@ -24,4 +25,5 @@ __all__ = [
|
|||||||
"Principal",
|
"Principal",
|
||||||
"PrincipalMembership",
|
"PrincipalMembership",
|
||||||
"OrgInviteToken",
|
"OrgInviteToken",
|
||||||
|
"OrganizationApiKey",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class Department(BaseModel):
|
|||||||
)
|
)
|
||||||
name = db.Column(db.String(255), nullable=False, index=True)
|
name = db.Column(db.String(255), nullable=False, index=True)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
can_sudo = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
organization = db.relationship("Organization", back_populates="departments")
|
organization = db.relationship("Organization", back_populates="departments")
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ from gatehouse_app.extensions import db
|
|||||||
from gatehouse_app.models.base import BaseModel
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
# Standard SSH certificate extensions
|
# Standard SSH certificate extensions — must be in strict lexical order
|
||||||
|
# (OpenSSH RFC 4251 §5 / golang.org/x/crypto/ssh requires lexical ordering)
|
||||||
STANDARD_EXTENSIONS = [
|
STANDARD_EXTENSIONS = [
|
||||||
"permit-X11-forwarding",
|
"permit-X11-forwarding",
|
||||||
"permit-agent-forwarding",
|
"permit-agent-forwarding",
|
||||||
"permit-pty",
|
|
||||||
"permit-port-forwarding",
|
"permit-port-forwarding",
|
||||||
|
"permit-pty",
|
||||||
"permit-user-rc",
|
"permit-user-rc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ class Organization(BaseModel):
|
|||||||
cas = db.relationship(
|
cas = db.relationship(
|
||||||
"CA", back_populates="organization", cascade="all, delete-orphan"
|
"CA", back_populates="organization", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
api_keys = db.relationship(
|
||||||
|
"OrganizationApiKey", back_populates="organization", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""String representation of Organization."""
|
"""String representation of Organization."""
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Organization API Key model — API keys for organizations for external integrations."""
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from gatehouse_app.extensions import db
|
||||||
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationApiKey(BaseModel):
|
||||||
|
"""API Key model representing an API key for an organization.
|
||||||
|
|
||||||
|
API keys are used to authenticate external integrations or services
|
||||||
|
that need programmatic access to the organization's resources.
|
||||||
|
Each key is tied to an organization and can be revoked/deleted as needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "organization_api_keys"
|
||||||
|
|
||||||
|
organization_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("organizations.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Human-readable name for the API key
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
# Hashed key value (never store plain text)
|
||||||
|
key_hash = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# Last used timestamp for tracking activity
|
||||||
|
last_used_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Revocation status
|
||||||
|
is_revoked = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||||
|
revoked_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
revoke_reason = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
# Description/purpose of the key
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = db.relationship("Organization", back_populates="api_keys")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.Index("idx_org_api_key_org_active", "organization_id", "is_revoked"),
|
||||||
|
db.Index("idx_api_key_last_used", "last_used_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""String representation of OrganizationApiKey."""
|
||||||
|
return f"<OrganizationApiKey name={self.name} org_id={self.organization_id}>"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_key() -> str:
|
||||||
|
"""Generate a random API key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A random 32-byte hex string suitable for use as an API key
|
||||||
|
"""
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_key(
|
||||||
|
cls,
|
||||||
|
organization_id: str,
|
||||||
|
name: str,
|
||||||
|
description: str = None,
|
||||||
|
) -> tuple:
|
||||||
|
"""Create and store a new API key for an organization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
organization_id: ID of the organization
|
||||||
|
name: Human-readable name for the key
|
||||||
|
description: Optional description/purpose of the key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (OrganizationApiKey instance, plain_text_key_string)
|
||||||
|
The plain text key is only returned on creation and should be
|
||||||
|
stored securely by the user. It cannot be retrieved later.
|
||||||
|
"""
|
||||||
|
# Generate a plain text key
|
||||||
|
plain_key = cls.generate_key()
|
||||||
|
|
||||||
|
# Hash it using the key_hash method
|
||||||
|
key_hash = cls.hash_key(plain_key)
|
||||||
|
|
||||||
|
# Create the database record
|
||||||
|
api_key = cls(
|
||||||
|
organization_id=organization_id,
|
||||||
|
name=name,
|
||||||
|
key_hash=key_hash,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
api_key.save()
|
||||||
|
|
||||||
|
return api_key, plain_key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_key(plain_key: str) -> str:
|
||||||
|
"""Hash an API key for storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plain_key: The plain text API key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hashed version of the key
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
return hashlib.sha256(plain_key.encode()).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def verify_key(cls, organization_id: str, plain_key: str) -> "OrganizationApiKey":
|
||||||
|
"""Verify an API key for an organization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
organization_id: ID of the organization
|
||||||
|
plain_key: The plain text API key to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OrganizationApiKey instance if valid and active, None otherwise
|
||||||
|
"""
|
||||||
|
key_hash = cls.hash_key(plain_key)
|
||||||
|
|
||||||
|
api_key = cls.query.filter_by(
|
||||||
|
organization_id=organization_id,
|
||||||
|
key_hash=key_hash,
|
||||||
|
is_revoked=False,
|
||||||
|
deleted_at=None,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
# Update last used timestamp
|
||||||
|
api_key.last_used_at = datetime.now(timezone.utc)
|
||||||
|
api_key.save()
|
||||||
|
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
def revoke(self, reason: str = None) -> None:
|
||||||
|
"""Revoke this API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reason: Optional reason for revocation
|
||||||
|
"""
|
||||||
|
self.is_revoked = True
|
||||||
|
self.revoked_at = datetime.now(timezone.utc)
|
||||||
|
self.revoke_reason = reason
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def to_dict(self, exclude=None):
|
||||||
|
"""Convert API key to dictionary.
|
||||||
|
|
||||||
|
The key_hash is excluded by default for security.
|
||||||
|
"""
|
||||||
|
exclude = exclude or []
|
||||||
|
if "key_hash" not in exclude:
|
||||||
|
exclude.append("key_hash")
|
||||||
|
return super().to_dict(exclude=exclude)
|
||||||
@@ -288,6 +288,12 @@ class SSHCASigningService:
|
|||||||
else:
|
else:
|
||||||
extensions = [] # host certs: no extensions
|
extensions = [] # host certs: no extensions
|
||||||
|
|
||||||
|
# OpenSSH (RFC 4251 §5) and golang.org/x/crypto/ssh require
|
||||||
|
# certificate extensions to be in strict lexical (alphabetical) order.
|
||||||
|
# Sort unconditionally so any caller-supplied or policy-derived list
|
||||||
|
# is guaranteed to be compliant.
|
||||||
|
extensions = sorted(extensions)
|
||||||
|
|
||||||
certificate.fields.extensions = extensions
|
certificate.fields.extensions = extensions
|
||||||
certificate.fields.critical_options = signing_request.critical_options or {}
|
certificate.fields.critical_options = signing_request.critical_options or {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Seed CA serial counters with a timestamp-based starting value.
|
||||||
|
|
||||||
|
Revision ID: 020_ca_serial_timestamp_start
|
||||||
|
Revises: 019_audit_varchar, d34bfb72844e
|
||||||
|
Create Date: 2026-03-06
|
||||||
|
|
||||||
|
WHY
|
||||||
|
---
|
||||||
|
``next_serial_number`` was originally seeded at ``1`` for every CA
|
||||||
|
(``server_default="1"`` in migration 017). Because the
|
||||||
|
``ix_ssh_certificates_serial`` index enforces a globally-unique constraint on
|
||||||
|
the serial column, any two CAs issuing their first certificate would both try
|
||||||
|
to insert serial ``1``, causing a UniqueViolation.
|
||||||
|
|
||||||
|
FIX — new CAs
|
||||||
|
-------------
|
||||||
|
The CA model's Python-side ``default`` is now ``_serial_start()``, which
|
||||||
|
returns ``int(time.time() * 1000)`` (Unix milliseconds) at row-creation time.
|
||||||
|
CAs created after this migration will start their serial counter at the
|
||||||
|
millisecond they were first inserted, so serials are globally unique across
|
||||||
|
CAs and still monotonically increasing within each CA.
|
||||||
|
|
||||||
|
FIX — existing CAs
|
||||||
|
-------------------
|
||||||
|
This migration performs a data migration: any CA whose ``next_serial_number``
|
||||||
|
is still ``<= 2`` (i.e. has issued at most one certificate since the original
|
||||||
|
``1``-based default) is given a new timestamp-based starting value.
|
||||||
|
|
||||||
|
CAs that have already issued many certificates keep their current counter
|
||||||
|
unchanged — their serials are already beyond the low collision-prone range.
|
||||||
|
|
||||||
|
NOTE: the ``server_default`` on the column is intentionally NOT changed here
|
||||||
|
because SQLAlchemy uses the Python-side ``default=_serial_start`` callable for
|
||||||
|
new rows; the ``server_default`` is only a database-level fallback that is
|
||||||
|
never hit when rows are inserted via the ORM.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "020_ca_serial_timestamp_start"
|
||||||
|
down_revision = ("3de11c5dc2d5", "d34bfb72844e")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Update ALL CAs to a timestamp-based starting serial — not just those
|
||||||
|
# stuck at 1. Any CA with a serial below the current ms timestamp is in
|
||||||
|
# the low collision-prone range (serials 1–N where N is tiny). Resetting
|
||||||
|
# every CA to a fresh ms timestamp is safe: the counter only moves forward
|
||||||
|
# from here, and no existing certificate serial is changed.
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text("SELECT id FROM cas")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for (ca_id,) in rows:
|
||||||
|
new_start = _now_ms()
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE cas SET next_serial_number = :val WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"val": new_start, "id": ca_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# There is no safe downgrade for a data migration that assigns new serial
|
||||||
|
# starting points — resetting to 1 would recreate the collision risk.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add_cert_token_to_ssh_certificates
|
||||||
|
|
||||||
|
Revision ID: 3de11c5dc2d5
|
||||||
|
Revises: 019_audit_varchar
|
||||||
|
Create Date: 2026-03-06 16:04:33.561099
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3de11c5dc2d5'
|
||||||
|
down_revision = '019_audit_varchar'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('ssh_certificates', sa.Column('cert_token', sa.String(length=64), nullable=True))
|
||||||
|
op.create_index(op.f('ix_ssh_certificates_cert_token'), 'ssh_certificates', ['cert_token'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_ssh_certificates_cert_token'), table_name='ssh_certificates')
|
||||||
|
op.drop_column('ssh_certificates', 'cert_token')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add can_sudo column to departments table.
|
||||||
|
|
||||||
|
Revision ID: 002_add_can_sudo_to_departments
|
||||||
|
Revises: 001_add_org_api_keys
|
||||||
|
Create Date: 2026-03-07 23:40:30.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '002_add_can_sudo_to_departments'
|
||||||
|
down_revision = '001_add_org_api_keys'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add can_sudo column to departments table
|
||||||
|
op.add_column('departments',
|
||||||
|
sa.Column('can_sudo', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
# Create index for performance
|
||||||
|
op.create_index('idx_dept_can_sudo', 'departments',
|
||||||
|
['organization_id', 'can_sudo'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop index
|
||||||
|
op.drop_index('idx_dept_can_sudo', table_name='departments')
|
||||||
|
|
||||||
|
# Drop column
|
||||||
|
op.drop_column('departments', 'can_sudo')
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Add organization_api_keys table for API key management.
|
||||||
|
|
||||||
|
Revision ID: 001_add_org_api_keys
|
||||||
|
Revises: 3de11c5dc2d5
|
||||||
|
Create Date: 2026-03-07 23:40:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '001_add_org_api_keys'
|
||||||
|
down_revision = '3de11c5dc2d5'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create organization_api_keys table
|
||||||
|
op.create_table(
|
||||||
|
'organization_api_keys',
|
||||||
|
sa.Column('id', sa.String(36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('organization_id', sa.String(36), nullable=False),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('key_hash', sa.String(255), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('is_revoked', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('revoke_reason', sa.String(255), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('key_hash'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for performance
|
||||||
|
op.create_index('idx_org_api_key_org_active', 'organization_api_keys',
|
||||||
|
['organization_id', 'is_revoked'])
|
||||||
|
op.create_index('idx_api_key_last_used', 'organization_api_keys',
|
||||||
|
['last_used_at'])
|
||||||
|
op.create_index('idx_org_api_key_org_id', 'organization_api_keys',
|
||||||
|
['organization_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop indexes
|
||||||
|
op.drop_index('idx_org_api_key_org_id', table_name='organization_api_keys')
|
||||||
|
op.drop_index('idx_api_key_last_used', table_name='organization_api_keys')
|
||||||
|
op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys')
|
||||||
|
|
||||||
|
# Drop table
|
||||||
|
op.drop_table('organization_api_keys')
|
||||||
Reference in New Issue
Block a user