feat(ssh): add multi-organization support for certificate signing

Add support for users who belong to multiple organizations to select
which organization's CA should sign their SSH certificates.

Changes:
- CLI: Add --org-id and --list-orgs options for organization selection
- API: Return MULTIPLE_ORGS_AMBIGUOUS error when org selection needed
- API: Add /users/me/organizations/simple endpoint for CLI org listing
- DB: Add organization_id to certificate_audit_logs for better tracking
- Include organization_name in certificate response for clarity
This commit is contained in:
2026-04-24 22:27:24 +09:30
parent 015c622016
commit cec04f3cb2
8 changed files with 314 additions and 46 deletions
+67 -17
View File
@@ -142,6 +142,55 @@ def get_my_organizations():
return api_response(data={"organizations": orgs, "count": len(orgs)}, message="Organizations retrieved successfully")
@api_v1_bp.route("/users/me/organizations/simple", methods=["GET"])
@login_required
def get_my_organizations_simple():
"""Lightweight organization list for CLI tool.
Returns organizations with CA status indicators for CLI users.
"""
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.ssh_ca.ca import CA, CaType
user = g.current_user
memberships = OrganizationMember.query.filter_by(user_id=user.id, deleted_at=None).all()
orgs = []
for membership in memberships:
org = membership.organization
if not org or org.deleted_at is not None:
continue
# Check for active CAs
user_ca = CA.query.filter_by(
organization_id=org.id,
ca_type=CaType.USER,
is_active=True,
deleted_at=None,
).first()
host_ca = CA.query.filter_by(
organization_id=org.id,
ca_type=CaType.HOST,
is_active=True,
deleted_at=None,
).first()
orgs.append({
"id": str(org.id),
"name": org.name,
"slug": getattr(org, 'slug', None),
"role": membership.role.value if hasattr(membership.role, "value") else str(membership.role),
"has_user_ca": user_ca is not None,
"has_host_ca": host_ca is not None,
})
return api_response(
data={"organizations": orgs, "count": len(orgs)},
message="Organizations retrieved successfully",
)
@api_v1_bp.route("/users/me/principals", methods=["GET"])
@login_required
@full_access_required
@@ -182,12 +231,11 @@ def get_my_principals():
my_principals = []
if effective_principal_ids:
for p in Principal.query.filter(
Principal.id.in_(list(effective_principal_ids)),
Principal.deleted_at == None,
).all():
for p in Principal.query.filter(Principal.id.in_(list(effective_principal_ids)), Principal.deleted_at == None).all():
my_principals.append({
"id": p.id, "name": p.name, "description": p.description,
"id": p.id,
"name": p.name,
"description": p.description,
"direct": p.id in direct_principal_ids,
})
@@ -197,7 +245,8 @@ def get_my_principals():
all_principals.append({"id": p.id, "name": p.name, "description": p.description})
orgs_result.append({
"org_id": org.id, "org_name": org.name,
"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,
@@ -241,6 +290,7 @@ def get_my_pending_invites():
@api_v1_bp.route("/users/me/memberships", methods=["GET"])
@login_required
@full_access_required
def get_my_memberships():
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal, Department
@@ -258,15 +308,15 @@ def get_my_memberships():
dept_memberships = DepartmentMembership.query.filter_by(user_id=user.id, deleted_at=None).all()
user_depts = [
dm.department for dm in dept_memberships
if dm.department
and dm.department.organization_id == org.id
and dm.department.deleted_at is None
dm.department
for dm in dept_memberships
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None
]
direct_pm = PrincipalMembership.query.filter_by(user_id=user.id, deleted_at=None).all()
direct_principal_ids = {
pm.principal_id for pm in direct_pm
pm.principal_id
for pm in direct_pm
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None
}
@@ -279,18 +329,18 @@ def get_my_memberships():
all_principal_ids = direct_principal_ids | via_dept_principal_ids
principals_list = []
if all_principal_ids:
for p in Principal.query.filter(
Principal.id.in_(list(all_principal_ids)),
Principal.deleted_at == None,
).all():
for p in Principal.query.filter(Principal.id.in_(list(all_principal_ids)), Principal.deleted_at == None).all():
principals_list.append({
"id": str(p.id), "name": p.name, "description": p.description,
"id": str(p.id),
"name": p.name,
"description": p.description,
"via_department": p.id not in direct_principal_ids,
})
role = membership.role
orgs_result.append({
"org_id": str(org.id), "org_name": org.name,
"org_id": str(org.id),
"org_name": org.name,
"role": role.value if hasattr(role, "value") else role,
"departments": [{"id": str(d.id), "name": d.name, "description": d.description} for d in user_depts],
"principals": principals_list,