Merge pull request #37 from CoryHawkless/oidc-uplift

OIDC uplift
This commit is contained in:
2026-05-19 14:48:58 +09:30
committed by GitHub
31 changed files with 1808 additions and 135 deletions
+56
View File
@@ -10,6 +10,8 @@ from gatehouse_app.models import Department, DepartmentMembership
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.extensions import db
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
class DepartmentCreateSchema(Schema):
@@ -127,6 +129,15 @@ def create_department(org_id):
db.session.add(dept)
db.session.commit()
AuditService.log_action(
action=AuditAction.DEPARTMENT_CREATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="department",
resource_id=str(dept.id),
description=f"Department '{dept.name}' created",
)
return api_response(
data={"department": dept.to_dict()},
message="Department created successfully",
@@ -255,6 +266,15 @@ def update_department(org_id, dept_id):
db.session.commit()
AuditService.log_action(
action=AuditAction.DEPARTMENT_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="department",
resource_id=str(dept.id),
description=f"Department '{dept.name}' updated",
)
return api_response(
data={"department": dept.to_dict()},
message="Department updated successfully",
@@ -308,6 +328,15 @@ def delete_department(org_id, dept_id):
dept.deleted_at = db.func.now()
db.session.commit()
AuditService.log_action(
action=AuditAction.DEPARTMENT_DELETED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="department",
resource_id=str(dept.id),
description=f"Department '{dept.name}' deleted",
)
return api_response(
message="Department deleted successfully",
)
@@ -461,6 +490,15 @@ def add_department_member(org_id, dept_id):
db.session.commit()
AuditService.log_action(
action=AuditAction.DEPARTMENT_MEMBER_ADDED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=str(user.id),
description=f"Added user {user.email} to department '{dept.name}'",
)
member_dict = membership.to_dict()
member_dict["user"] = user.to_dict()
@@ -533,6 +571,15 @@ def remove_department_member(org_id, dept_id, user_id):
membership.deleted_at = db.func.now()
db.session.commit()
AuditService.log_action(
action=AuditAction.DEPARTMENT_MEMBER_REMOVED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=str(user_id),
description=f"Removed user from department '{dept.name}'",
)
return api_response(
message="Member removed successfully",
)
@@ -699,5 +746,14 @@ def set_dept_cert_policy(org_id, dept_id):
db.session.commit()
AuditService.log_action(
action=AuditAction.DEPARTMENT_CERT_POLICY_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="department",
resource_id=str(dept_id),
description=f"Certificate policy updated for department '{dept.name}'",
)
return api_response(data={"cert_policy": policy.to_dict()}, message="Certificate policy saved")
@@ -3,6 +3,8 @@ from flask import g, request
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
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
@@ -78,6 +80,14 @@ def admin_configure_app_provider(provider: str):
db.session.add(cfg)
db.session.commit()
AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_CONFIG_UPDATE if cfg else AuditAction.EXTERNAL_AUTH_CONFIG_CREATE,
user_id=g.current_user.id,
resource_type="oauth_provider",
resource_id=provider,
description=f"OAuth provider '{provider}' configured (enabled={cfg.is_enabled})",
)
return api_response(
data={"provider": {"id": provider, "client_id": cfg.client_id, "is_enabled": cfg.is_enabled}},
message=f"{provider.capitalize()} OAuth provider configured successfully",
@@ -104,4 +114,13 @@ def admin_delete_app_provider(provider: str):
return api_response(success=False, message=f"Provider '{provider}' is not configured", status=404, error_type="NOT_FOUND")
cfg.delete()
AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_CONFIG_DELETE,
user_id=g.current_user.id,
resource_type="oauth_provider",
resource_id=provider,
description=f"OAuth provider '{provider}' configuration removed",
)
return api_response(message=f"{provider.capitalize()} OAuth provider configuration removed")
+15
View File
@@ -26,6 +26,9 @@ from gatehouse_app.exceptions.auth_exceptions import (
AccountSuspendedError,
AccountInactiveError,
)
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
logger = logging.getLogger(__name__)
@@ -849,6 +852,18 @@ def oidc_register():
)
client.save()
OIDCAuditService.log_event(
event_type="client_registration",
client_id=client_id,
user_id=g.current_user.id if hasattr(g, "current_user") else None,
success=True,
metadata={
"client_name": client_name,
"redirect_uris": redirect_uris,
"organization_id": str(organization.id),
},
)
response = jsonify({
"client_id": client_id,
"client_secret": client_secret,
+33 -4
View File
@@ -8,6 +8,8 @@ from gatehouse_app.utils.decorators import login_required, require_admin, full_a
from gatehouse_app.models.organization import OrganizationApiKey
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.extensions import db
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
class ApiKeyCreateSchema(Schema):
@@ -130,7 +132,16 @@ def create_api_key(org_id):
name=data["name"],
description=data.get("description"),
)
AuditService.log_action(
action=AuditAction.ORG_API_KEY_CREATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="api_key",
resource_id=str(api_key.id),
description=f"API key '{api_key.name}' created",
)
# 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
@@ -219,9 +230,18 @@ def update_api_key(org_id, key_id):
api_key.name = data["name"]
if "description" in data:
api_key.description = data["description"]
api_key.save()
AuditService.log_action(
action=AuditAction.ORG_API_KEY_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="api_key",
resource_id=str(api_key.id),
description=f"API key '{api_key.name}' updated",
)
return api_response(
data={"api_key": api_key.to_dict()},
message="API key updated successfully",
@@ -293,7 +313,16 @@ def delete_api_key(org_id, key_id):
# Soft delete the API key
api_key.delete(soft=True)
AuditService.log_action(
action=AuditAction.ORG_API_KEY_DELETED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="api_key",
resource_id=str(api_key.id),
description=f"API key '{api_key.name}' deleted",
)
return api_response(
message="API key deleted successfully",
)
+21
View File
@@ -6,6 +6,8 @@ from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin
from gatehouse_app.extensions import db
from gatehouse_app.api.v1.organizations._helpers import _get_system_ca_dict
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/organizations/<org_id>/cas", methods=["GET"])
@@ -66,6 +68,16 @@ def update_org_ca(org_id, ca_id):
ca.max_cert_validity_hours = data["max_cert_validity_hours"]
db.session.commit()
AuditService.log_action(
action=AuditAction.CA_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="CA",
resource_id=ca_id,
description=f"CA '{ca.name}' updated",
)
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)
@@ -150,6 +162,15 @@ def create_org_ca(org_id):
return api_response(success=False, message="A CA with that name already exists in this organization (it may have been recently deleted — choose a different name).", status=400, error_type="DUPLICATE_NAME")
raise
AuditService.log_action(
action=AuditAction.CA_CREATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="CA",
resource_id=str(ca.id),
description=f"CA '{ca.name}' created",
)
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)
@@ -5,6 +5,8 @@ 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.extensions import db, bcrypt
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
@@ -28,6 +30,7 @@ def list_org_clients(org_id):
"redirect_uris": c.redirect_uris,
"scopes": c.scopes,
"grant_types": c.grant_types,
"allowed_cors_origins": c.allowed_cors_origins,
"is_active": c.is_active,
"created_at": c.created_at.isoformat() + "Z",
}
@@ -78,6 +81,15 @@ def create_org_client(org_id):
db.session.add(client)
db.session.commit()
AuditService.log_action(
action=AuditAction.ORG_CLIENT_CREATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="oidc_client",
resource_id=str(client.id),
description=f"OIDC client '{client.name}' created",
)
return api_response(
data={
"client": {
@@ -125,6 +137,15 @@ def update_org_client(org_id, client_id):
db.session.commit()
AuditService.log_action(
action=AuditAction.ORG_CLIENT_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="oidc_client",
resource_id=str(client.id),
description=f"OIDC client '{client.name}' updated",
)
return api_response(
data={
"client": {
@@ -154,4 +175,14 @@ def delete_org_client(org_id, client_id):
client.is_active = False
db.session.commit()
AuditService.log_action(
action=AuditAction.ORG_CLIENT_DEACTIVATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="oidc_client",
resource_id=str(client.id),
description=f"OIDC client '{client.name}' deactivated",
)
return api_response(data={}, message="Client deactivated successfully")
@@ -7,6 +7,8 @@ from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.schemas.organization_schema import OrganizationCreateSchema, OrganizationUpdateSchema
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/organizations", methods=["POST"])
@@ -32,6 +34,14 @@ def create_organization():
description=data.get("description"),
logo_url=data.get("logo_url"),
)
AuditService.log_action(
action=AuditAction.ORG_CREATE,
user_id=g.current_user.id,
organization_id=org.id,
resource_type="organization",
resource_id=str(org.id),
description=f"Organization '{org.name}' created",
)
return api_response(data={"organization": org.to_dict()}, message="Organization created successfully", status=201)
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
@@ -60,6 +70,14 @@ def update_organization(org_id):
data = schema.load(request.json)
org = OrganizationService.get_organization_by_id(org_id)
org = OrganizationService.update_organization(org=org, user_id=g.current_user.id, **data)
AuditService.log_action(
action=AuditAction.ORG_UPDATE,
user_id=g.current_user.id,
organization_id=org.id,
resource_type="organization",
resource_id=str(org.id),
description=f"Organization '{org.name}' updated",
)
return api_response(data={"organization": org.to_dict()}, message="Organization updated successfully")
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
@@ -92,4 +110,12 @@ def delete_organization(org_id):
)
OrganizationService.force_delete_organization(org=org, user_id=caller.id)
AuditService.log_action(
action=AuditAction.ORG_DELETE,
user_id=caller.id,
organization_id=org.id,
resource_type="organization",
resource_id=str(org.id),
description=f"Organization '{org.name}' deleted",
)
return api_response(message="Organization deleted successfully")
@@ -136,6 +136,15 @@ def cancel_org_invite(org_id, invite_id):
return api_response(success=False, message="Invite not found", status=404)
invite.delete(soft=True)
AuditService.log_action(
action=AuditAction.ORG_INVITE_CANCELLED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="org_invite",
resource_id=invite.id,
metadata={"invited_email": invite.email, "role": invite.role},
description=f"Invitation for {invite.email} cancelled",
)
return api_response(data={}, message="Invite cancelled")
@@ -243,6 +252,30 @@ def accept_invite(token):
invite.accept()
if invite.invited_by and invite.invited_by.email:
from gatehouse_app.services.email_templates import build_invite_accepted_html
from gatehouse_app.services.notification_service import NotificationService
member_display = user.full_name or user.email
inviter_display = invite.invited_by.full_name or invite.invited_by.email
org_link = f"{current_app.config.get('APP_URL', '')}/organizations/{invite.organization_id}"
html_body = build_invite_accepted_html(
inviter_name=inviter_display,
member_name=member_display,
member_email=user.email,
org_name=invite.organization.name,
role=invite.role,
org_link=org_link,
)
NotificationService._send_email_async(
to_address=invite.invited_by.email,
subject=f"{member_display} accepted your invitation to {invite.organization.name}",
body=f"{member_display} has accepted your invitation to join {invite.organization.name} on Secuird.",
html_body=html_body,
)
has_webauthn = user.has_webauthn_enabled()
has_totp = user.has_totp_enabled()
+35 -1
View File
@@ -7,7 +7,8 @@ from gatehouse_app.utils.decorators import login_required, require_admin, full_a
from gatehouse_app.schemas.organization_schema import InviteMemberSchema, UpdateMemberRoleSchema
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.utils.constants import AuditAction, OrganizationRole
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/organizations/<org_id>/members", methods=["GET"])
@@ -43,6 +44,14 @@ def add_organization_member(org_id):
role = OrganizationRole(data["role"])
member = OrganizationService.add_member(org=org, user_id=user.id, role=role, inviter_id=g.current_user.id)
AuditService.log_action(
action=AuditAction.ORG_MEMBER_ADD,
user_id=g.current_user.id,
organization_id=org.id,
resource_type="user",
resource_id=str(user.id),
description=f"Added user {user.email} to organization with role {role.value}",
)
member_dict = member.to_dict()
member_dict["user"] = user.to_dict()
return api_response(data={"member": member_dict}, message="Member added successfully", status=201)
@@ -60,6 +69,14 @@ def remove_organization_member(org_id, user_id):
OrganizationService.remove_member(org=org, user_id=user_id, remover_id=g.current_user.id)
except ValueError as e:
return api_response(success=False, message=str(e), status=403, error_type="OWNER_PROTECTION")
AuditService.log_action(
action=AuditAction.ORG_MEMBER_REMOVE,
user_id=g.current_user.id,
organization_id=org.id,
resource_type="user",
resource_id=str(user_id),
description=f"Removed user {user_id} from organization",
)
return api_response(message="Member removed successfully")
@@ -74,6 +91,14 @@ def update_member_role(org_id, user_id):
org = OrganizationService.get_organization_by_id(org_id)
new_role = OrganizationRole(data["role"])
member = OrganizationService.update_member_role(org=org, user_id=user_id, new_role=new_role, updater_id=g.current_user.id)
AuditService.log_action(
action=AuditAction.ORG_MEMBER_ROLE_CHANGE,
user_id=g.current_user.id,
organization_id=org.id,
resource_type="user",
resource_id=str(user_id),
description=f"Changed role for user {user_id} to {new_role.value}",
)
member_dict = member.to_dict()
member_dict["user"] = member.user.to_dict()
return api_response(data={"member": member_dict}, message="Member role updated successfully")
@@ -180,4 +205,13 @@ def send_mfa_reminder(org_id, user_id):
html_body=html_body,
)
AuditService.log_action(
action=AuditAction.ORG_MFA_REMINDER_SENT,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=str(user_id),
description=f"MFA reminder sent to {user.email}",
)
return api_response(data={}, message="Reminder sent successfully")
+22 -1
View File
@@ -3,8 +3,9 @@ from flask import g, request
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.utils.constants import OrganizationRole
from gatehouse_app.utils.constants import AuditAction, OrganizationRole
from gatehouse_app.extensions import db
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/organizations/<org_id>/roles", methods=["GET"])
@@ -59,6 +60,16 @@ def assign_role_to_member(org_id, role_name):
membership.role = new_role
db.session.commit()
AuditService.log_action(
action=AuditAction.ORG_MEMBER_ROLE_CHANGE,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=str(target_user_id),
description=f"Role changed to {new_role.value} for user {target_user_id}",
)
return api_response(data={"user_id": target_user_id, "role": new_role.value}, message=f"Role updated to {new_role.value}")
@@ -82,4 +93,14 @@ def remove_role_from_member(org_id, role_name, user_id):
org = OrganizationService.get_organization_by_id(org_id)
OrganizationService.remove_member(org=org, user_id=user_id, remover_id=g.current_user.id)
AuditService.log_action(
action=AuditAction.ORG_MEMBER_REMOVE,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=str(user_id),
description=f"Member {user_id} removed from organization via role removal",
)
return api_response(data={"user_id": user_id}, message="Member removed from organization")
+65
View File
@@ -10,6 +10,8 @@ from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.exceptions import OrganizationNotFoundError
from gatehouse_app.extensions import db
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
class PrincipalCreateSchema(Schema):
@@ -127,6 +129,15 @@ def create_principal(org_id):
db.session.add(principal)
db.session.commit()
AuditService.log_action(
action=AuditAction.PRINCIPAL_CREATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="principal",
resource_id=str(principal.id),
description=f"Principal '{principal.name}' created",
)
return api_response(
data={"principal": principal.to_dict()},
message="Principal created successfully",
@@ -255,6 +266,15 @@ def update_principal(org_id, principal_id):
db.session.commit()
AuditService.log_action(
action=AuditAction.PRINCIPAL_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="principal",
resource_id=str(principal.id),
description=f"Principal '{principal.name}' updated",
)
return api_response(
data={"principal": principal.to_dict()},
message="Principal updated successfully",
@@ -308,6 +328,15 @@ def delete_principal(org_id, principal_id):
principal.deleted_at = db.func.now()
db.session.commit()
AuditService.log_action(
action=AuditAction.PRINCIPAL_DELETED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="principal",
resource_id=str(principal.id),
description=f"Principal '{principal.name}' deleted",
)
return api_response(
message="Principal deleted successfully",
)
@@ -476,6 +505,15 @@ def add_principal_member(org_id, principal_id):
db.session.commit()
AuditService.log_action(
action=AuditAction.PRINCIPAL_MEMBER_ADDED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=str(user.id),
description=f"Added user {user.email} to principal '{principal.name}'",
)
member_dict = membership.to_dict()
member_dict["user"] = user.to_dict()
@@ -548,6 +586,15 @@ def remove_principal_member(org_id, principal_id, user_id):
membership.deleted_at = db.func.now()
db.session.commit()
AuditService.log_action(
action=AuditAction.PRINCIPAL_MEMBER_REMOVED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=str(user_id),
description=f"Removed user from principal '{principal.name}'",
)
return api_response(
message="Member removed successfully",
)
@@ -697,6 +744,15 @@ def link_principal_to_department(org_id, principal_id, dept_id):
error_type="SERVER_ERROR",
)
AuditService.log_action(
action=AuditAction.PRINCIPAL_DEPARTMENT_LINKED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="principal",
resource_id=str(principal_id),
description=f"Principal '{principal.name}' linked to department '{dept.name}'",
)
return api_response(
data={
"principal": principal.to_dict(),
@@ -774,6 +830,15 @@ def unlink_principal_from_department(org_id, principal_id, dept_id):
link.deleted_at = db.func.now()
db.session.commit()
AuditService.log_action(
action=AuditAction.PRINCIPAL_DEPARTMENT_UNLINKED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="principal",
resource_id=str(principal_id),
description=f"Principal '{principal.name}' unlinked from department '{dept.name}'",
)
return api_response(
message="Principal unlinked from department successfully",
)
+2
View File
@@ -9,6 +9,7 @@ from gatehouse_app.utils.response import api_response
from gatehouse_app.services.superadmin_auth_service import SuperadminAuthService
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.utils.constants import AuditAction
logger = logging.getLogger(__name__)
@@ -105,6 +106,7 @@ def login():
@superadmin_bp.route("/auth/logout", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action=AuditAction.USER_LOGOUT, resource_type="session")
def logout():
"""Superadmin logout endpoint.
+10 -6
View File
@@ -15,7 +15,7 @@ def superadmin_required(f):
"""Decorator to require superadmin Bearer token authentication.
Extracts token from Authorization: Bearer {token} header,
validates the session against SuperadminSession table,
validates the session against the unified sessions table,
and sets g.current_superadmin and g.superadmin_session.
Returns 401 if no valid session, 403 if not a superadmin.
@@ -46,10 +46,14 @@ def superadmin_required(f):
token = parts[1]
# Import here to avoid circular imports
from gatehouse_app.models.superadmin import SuperadminSession, Superadmin
from gatehouse_app.models.user.session import Session
from gatehouse_app.models.superadmin import Superadmin
from gatehouse_app.utils.constants import SessionType
# Get active session by token
session = SuperadminSession.query.filter_by(token=token).first()
# Get active session by token, scoped to superadmin
session = Session.query.filter_by(
token=token, owner_type=SessionType.SUPERADMIN
).first()
if not session:
return api_response(
@@ -68,8 +72,8 @@ def superadmin_required(f):
error_type="SESSION_INACTIVE"
)
# Get the superadmin
superadmin = session.superadmin
# Get the superadmin by owner_id
superadmin = Superadmin.query.get(session.owner_id)
if not superadmin:
return api_response(
success=False,
+87 -2
View File
@@ -1,6 +1,12 @@
"""CORS middleware configuration."""
import base64
import json
from urllib.parse import parse_qs
from flask import request, make_response
from gatehouse_app.models import OIDCClient
ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
ALLOWED_HEADERS = (
"Content-Type, Authorization, X-Requested-With, X-Request-ID, "
@@ -40,6 +46,85 @@ def _cors_origin_header(cors_origins, request_origin):
return None
def _get_oidc_client_id_from_request():
"""Extract client_id from OIDC endpoint requests."""
path = request.path
# POST to /oidc/token, /oidc/revoke, /oidc/introspect
if request.method == "POST" and any(
path.endswith(ep) for ep in ("/oidc/token", "/oidc/revoke", "/oidc/introspect")
):
# Try Basic Auth header first
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
client_id, _, _ = decoded.partition(":")
if client_id:
return client_id
except Exception:
pass
# Try form body
if request.form:
client_id = request.form.get("client_id")
if client_id:
return client_id
# Try JSON body
if request.is_json:
try:
client_id = request.json.get("client_id")
if client_id:
return client_id
except Exception:
pass
return None
# GET/POST to /oidc/userinfo
if path.endswith("/oidc/userinfo"):
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
try:
payload_b64 = token.split(".")[1]
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
return payload.get("client_id")
except Exception:
return None
return None
def _get_effective_cors_origins(app, request):
"""Get effective CORS origins, checking per-client config for OIDC endpoints."""
global_origins = app.config.get("CORS_ORIGINS", [])
if "/oidc/" not in request.path:
return global_origins
try:
client_id = _get_oidc_client_id_from_request()
if not client_id:
return global_origins
client = OIDCClient.query.filter_by(client_id=client_id).first()
if not client:
return global_origins
effective = client.get_effective_origins()
if effective is not None:
return effective
except Exception:
pass
return global_origins
def setup_cors(app):
"""
Configure CORS for the application.
@@ -54,7 +139,7 @@ def setup_cors(app):
"""Handle CORS preflight OPTIONS requests."""
if request.method == "OPTIONS":
origin = request.headers.get("Origin")
cors_origins = app.config.get("CORS_ORIGINS", [])
cors_origins = _get_effective_cors_origins(app, request)
if not _is_origin_allowed(origin, cors_origins):
return None
@@ -73,7 +158,7 @@ def setup_cors(app):
def after_request_cors(response):
"""Add CORS headers to non-preflight responses."""
origin = request.headers.get("Origin")
cors_origins = app.config.get("CORS_ORIGINS", [])
cors_origins = _get_effective_cors_origins(app, request)
allow_origin = _cors_origin_header(cors_origins, origin)
if allow_origin:
-2
View File
@@ -118,7 +118,6 @@ from gatehouse_app.models.zerotier import ( # noqa: F401
from gatehouse_app.models.superadmin import ( # noqa: F401
Superadmin,
SuperadminSession,
SuperadminSessionStatus,
)
from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog # noqa: F401
from gatehouse_app.models.security.user_security_policy import ( # noqa: F401
@@ -186,6 +185,5 @@ __all__ = [
# Superadmin
"Superadmin",
"SuperadminSession",
"SuperadminSessionStatus",
"SuperadminAuditLog",
]
+34
View File
@@ -1,4 +1,6 @@
"""OIDC Client model."""
from urllib.parse import urlparse
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import OIDCGrantType, OIDCResponseType
@@ -21,6 +23,7 @@ class OIDCClient(BaseModel):
grant_types = db.Column(db.JSON, nullable=False) # Allowed grant types
response_types = db.Column(db.JSON, nullable=False) # Allowed response types
scopes = db.Column(db.JSON, nullable=False) # Allowed scopes
allowed_cors_origins = db.Column(db.JSON, nullable=True, default=None) # Per-client CORS origins
# Client metadata
logo_uri = db.Column(db.String(512), nullable=True)
@@ -81,6 +84,37 @@ class OIDCClient(BaseModel):
"""Check if a redirect URI is allowed for this client."""
return redirect_uri in self.redirect_uris
def get_effective_origins(self) -> list | None:
"""Get effective CORS origins for this client.
Returns None to signal "use global config", a derived list from
redirect_uris when "+" is present, or the configured list as-is.
"""
if self.allowed_cors_origins is None:
return None
if "+" in self.allowed_cors_origins:
origins = set()
for uri in self.redirect_uris:
parsed = urlparse(uri)
if parsed.scheme and parsed.hostname:
port = f":{parsed.port}" if parsed.port else ""
origins.add(f"{parsed.scheme}://{parsed.hostname}{port}")
return sorted(origins)
return list(self.allowed_cors_origins)
def is_origin_allowed(self, origin: str) -> bool | None:
"""Check if a browser origin is allowed for CORS.
Returns True/False when a per-client list is configured,
or None to defer to the global CORS policy.
"""
effective = self.get_effective_origins()
if effective is None:
return None
if "*" in effective:
return True
return origin in effective
def has_scope(self, scope: str) -> bool:
"""Check if client is allowed to request a specific scope."""
return scope in self.scopes
+2 -2
View File
@@ -1,5 +1,5 @@
"""Superadmin models."""
from gatehouse_app.models.superadmin.superadmin import Superadmin
from gatehouse_app.models.superadmin.superadmin_session import SuperadminSession, SuperadminSessionStatus
from gatehouse_app.models.user.session import Session as SuperadminSession
__all__ = ["Superadmin", "SuperadminSession", "SuperadminSessionStatus"]
__all__ = ["Superadmin", "SuperadminSession"]
@@ -23,11 +23,15 @@ class Superadmin(BaseModel):
is_active = db.Column(db.Boolean, default=True, nullable=False)
last_login_at = db.Column(db.DateTime, nullable=True)
# Relationship to sessions
# Relationship to sessions (unified model, scoped to superadmin owner_type)
sessions = db.relationship(
"SuperadminSession",
back_populates="superadmin",
cascade="all, delete-orphan"
"Session",
primaryjoin=(
"and_(Superadmin.id == foreign(Session.owner_id), "
"Session.owner_type == 'superadmin')"
),
cascade="all, delete-orphan",
lazy="dynamic",
)
# Relationship to audit logs
@@ -1,80 +0,0 @@
"""Superadmin session model."""
import logging
from datetime import datetime, timezone, timedelta
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
logger = logging.getLogger(__name__)
class SuperadminSessionStatus:
"""Session status constants."""
ACTIVE = "active"
REVOKED = "revoked"
EXPIRED = "expired"
class SuperadminSession(BaseModel):
"""Session model for superadmin authentication."""
__tablename__ = "superadmin_sessions"
superadmin_id = db.Column(
db.String(36),
db.ForeignKey("superadmins.id"),
nullable=False,
index=True
)
token = db.Column(db.String(255), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False)
last_activity_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc)
)
ip_address = db.Column(db.String(45), nullable=True)
user_agent = db.Column(db.Text, nullable=True)
revoked_at = db.Column(db.DateTime, nullable=True)
revoked_reason = db.Column(db.String(255), nullable=True)
# Relationship
superadmin = db.relationship("Superadmin", back_populates="sessions")
def __repr__(self):
return f"<SuperadminSession superadmin_id={self.superadmin_id}>"
def is_active(self):
"""Check if session is currently active."""
now = datetime.now(timezone.utc)
expires_at = self.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return (
self.deleted_at is None
and self.revoked_at is None
and expires_at > now
)
def is_expired(self):
"""Check if session has expired."""
now = datetime.now(timezone.utc)
expires_at = self.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return now > expires_at
def revoke(self, reason: str = None):
"""Revoke the session."""
self.revoked_at = datetime.now(timezone.utc)
if reason:
self.revoked_reason = reason
from gatehouse_app import db
db.session.commit()
def to_dict(self, exclude=None):
"""Convert to dictionary, excluding sensitive fields."""
exclude = exclude or []
exclude.append("token")
return super().to_dict(exclude=exclude)
+54 -12
View File
@@ -3,15 +3,24 @@ from datetime import datetime, timedelta, timezone
from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import SessionStatus
from gatehouse_app.utils.constants import SessionStatus, SessionType
class Session(BaseModel):
"""Session model for tracking user sessions."""
"""Session model for tracking user and superadmin sessions."""
__tablename__ = "sessions"
user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True)
# Owner discriminator — determines which table the owner_id references
owner_type = db.Column(
db.String(20), nullable=False, default=SessionType.USER, index=True
)
owner_id = db.Column(db.String(36), nullable=False, index=True)
# Legacy column kept for backward compatibility during migration;
# new code should use owner_id / owner_type.
user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=True, index=True)
token = db.Column(db.String(255), unique=True, nullable=False, index=True)
status = db.Column(db.Enum(SessionStatus), default=SessionStatus.ACTIVE, nullable=False)
@@ -34,21 +43,37 @@ class Session(BaseModel):
# Relationships
user = db.relationship("User", back_populates="sessions")
# Composite index for owner-scoped queries
__table_args__ = (
db.Index("ix_sessions_owner_type_owner_id", "owner_type", "owner_id"),
)
# ---- Convenience properties ------------------------------------------------
@property
def is_user(self):
return self.owner_type == SessionType.USER
@property
def is_superadmin(self):
return self.owner_type == SessionType.SUPERADMIN
# ---- Core methods ----------------------------------------------------------
def __repr__(self):
"""String representation of Session."""
return f"<Session user_id={self.user_id} status={self.status}>"
return f"<Session owner_type={self.owner_type} owner_id={self.owner_id} status={self.status}>"
def is_active(self):
"""Check if session is currently active.
Sessions are evaluated against two independent timeouts:
User sessions are evaluated against two independent timeouts:
- Idle timeout: expires if no request has been made within
SESSION_IDLE_TIMEOUT seconds (default 15 min).
- Absolute timeout: expires if SESSION_ABSOLUTE_TIMEOUT seconds
have elapsed since the session was created (default 8 h),
regardless of activity.
have elapsed since the session was created (default 8 h).
A session must satisfy *both* constraints to remain active.
Superadmin sessions use absolute timeout only (no idle timeout).
A session must satisfy *all* applicable constraints to remain active.
"""
now = datetime.now(timezone.utc)
created_at = self.created_at
@@ -59,12 +84,21 @@ class Session(BaseModel):
if last_activity_at.tzinfo is None:
last_activity_at = last_activity_at.replace(tzinfo=timezone.utc)
idle_timeout = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
absolute_timeout = current_app.config.get("SESSION_ABSOLUTE_TIMEOUT", 28800)
idle_expires_at = last_activity_at + timedelta(seconds=idle_timeout)
absolute_expires_at = created_at + timedelta(seconds=absolute_timeout)
if self.is_superadmin:
# Superadmin: absolute timeout only
return (
self.status == SessionStatus.ACTIVE
and now < absolute_expires_at
and self.deleted_at is None
)
# User: idle + absolute timeout
idle_timeout = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
idle_expires_at = last_activity_at + timedelta(seconds=idle_timeout)
return (
self.status == SessionStatus.ACTIVE
and now < idle_expires_at
@@ -83,6 +117,8 @@ class Session(BaseModel):
capped so that the session never exceeds the absolute lifetime
(``created_at + absolute timeout``).
Superadmin sessions only update last_activity_at (no sliding window).
Args:
duration_seconds: Override for the idle timeout. When *None*
(the common case), the value is read from
@@ -90,6 +126,12 @@ class Session(BaseModel):
"""
now = datetime.now(timezone.utc)
if self.is_superadmin:
# Superadmin: just bump last_activity_at, no sliding window
self.last_activity_at = now
db.session.commit()
return
if duration_seconds is None:
duration_seconds = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
+3 -1
View File
@@ -8,7 +8,7 @@ from gatehouse_app.extensions import db, bcrypt
from gatehouse_app.models.user.user import User
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, SessionType, UserStatus, AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
from gatehouse_app.services.audit_service import AuditService
@@ -165,6 +165,8 @@ class AuthService:
# Create session
session = Session(
owner_type=SessionType.USER,
owner_id=user.id,
user_id=user.id,
token=token,
status=SessionStatus.ACTIVE,
+48
View File
@@ -562,3 +562,51 @@ def build_contact_enquiry_html(
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px; line-height: 1.6; white-space: pre-wrap;">{message_display}</p>
'''
return get_base_html(content, f"Secuird Website: {type_label}", f"New {type_label} from {submitter_email}")
def build_invite_accepted_html(
inviter_name: str,
member_name: str,
member_email: str,
org_name: str,
role: str,
org_link: Optional[str] = None,
) -> str:
"""Build invite accepted notification email.
Args:
inviter_name: Name of the person who sent the invite
member_name: Name of the person who accepted
member_email: Email of the person who accepted
org_name: Organization name
role: Role assigned to the member
org_link: Optional link to view the organization
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Invitation Accepted</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
<strong>{member_name}</strong> has accepted your invitation to join <strong>{org_name}</strong> on Secuird.
</p>
{get_alert_box(f"<strong>{member_name}</strong> ({member_email}) has joined <strong>{org_name}</strong>", "success", "")}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 16px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Membership Details</h3>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
{get_detail_row("Member", member_name)}
{get_detail_row("Email", member_email)}
{get_detail_row("Organization", org_name)}
{get_detail_row("Role", role)}
</table>
</td>
</tr>
</table>
'''
if org_link:
content += get_action_button(org_link, "View Organization", PRIMARY_COLOR)
return get_base_html(content, f"Invitation accepted: {org_name}", f"{member_name} has joined {org_name}")
+64 -11
View File
@@ -1,7 +1,7 @@
"""Session service."""
from datetime import datetime, timezone
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import SessionStatus
from gatehouse_app.utils.constants import SessionStatus, SessionType
class SessionService:
@@ -28,18 +28,22 @@ class SessionService:
).first()
@staticmethod
def get_user_sessions(user_id, active_only=True):
"""
Get all sessions for a user.
def get_owner_sessions(owner_type, owner_id, active_only=True):
"""Get all sessions for an owner (user or superadmin).
Args:
user_id: User ID
owner_type: SessionType.USER or SessionType.SUPERADMIN
owner_id: Owner ID
active_only: If True, only return active sessions
Returns:
List of Session instances
"""
query = Session.query.filter_by(user_id=user_id, deleted_at=None)
query = Session.query.filter_by(
owner_type=owner_type,
owner_id=owner_id,
deleted_at=None,
)
if active_only:
query = query.filter_by(status=SessionStatus.ACTIVE).filter(
@@ -49,18 +53,67 @@ class SessionService:
return query.all()
@staticmethod
def revoke_user_sessions(user_id, reason="User logged out from all devices"):
def get_user_sessions(user_id, active_only=True):
"""Get all sessions for a user.
Args:
user_id: User ID
active_only: If True, only return active sessions
Returns:
List of Session instances
"""
Revoke all active sessions for a user.
return SessionService.get_owner_sessions(
SessionType.USER, user_id, active_only=active_only
)
@staticmethod
def get_superadmin_sessions(superadmin_id, active_only=True):
"""Get all sessions for a superadmin.
Args:
superadmin_id: Superadmin ID
active_only: If True, only return active sessions
Returns:
List of Session instances
"""
return SessionService.get_owner_sessions(
SessionType.SUPERADMIN, superadmin_id, active_only=active_only
)
@staticmethod
def revoke_owner_sessions(owner_type, owner_id, reason="Logged out from all devices"):
"""Revoke all active sessions for an owner.
Args:
owner_type: SessionType.USER or SessionType.SUPERADMIN
owner_id: Owner ID
reason: Reason for revocation
"""
sessions = SessionService.get_owner_sessions(owner_type, owner_id, active_only=True)
for session in sessions:
session.revoke(reason=reason)
@staticmethod
def revoke_user_sessions(user_id, reason="User logged out from all devices"):
"""Revoke all active sessions for a user.
Args:
user_id: User ID
reason: Reason for revocation
"""
sessions = SessionService.get_user_sessions(user_id, active_only=True)
SessionService.revoke_owner_sessions(SessionType.USER, user_id, reason=reason)
for session in sessions:
session.revoke(reason=reason)
@staticmethod
def revoke_superadmin_sessions(superadmin_id, reason="Superadmin logged out"):
"""Revoke all active sessions for a superadmin.
Args:
superadmin_id: Superadmin ID
reason: Reason for revocation
"""
SessionService.revoke_owner_sessions(SessionType.SUPERADMIN, superadmin_id, reason=reason)
@staticmethod
def cleanup_expired_sessions():
@@ -6,7 +6,9 @@ from typing import Optional
from flask import request, current_app
from gatehouse_app.extensions import db, bcrypt
from gatehouse_app.models.superadmin import Superadmin, SuperadminSession
from gatehouse_app.models.superadmin import Superadmin
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import SessionType
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -70,15 +72,17 @@ class SuperadminAuthService:
duration_seconds: Session duration in seconds (default 8 hours)
Returns:
SuperadminSession instance
Session instance
"""
# Generate secure token
token = secrets.token_urlsafe(32)
# Create session
session = SuperadminSession(
superadmin_id=superadmin_id,
# Create session using unified model
session = Session(
owner_type=SessionType.SUPERADMIN,
owner_id=superadmin_id,
token=token,
status="active",
expires_at=datetime.now(timezone.utc) + timedelta(seconds=duration_seconds),
last_activity_at=datetime.now(timezone.utc),
ip_address=request.remote_addr,
@@ -97,7 +101,9 @@ class SuperadminAuthService:
session_id: Session ID to revoke
reason: Optional revocation reason
"""
session = SuperadminSession.query.get(session_id)
session = Session.query.filter_by(
id=session_id, owner_type=SessionType.SUPERADMIN
).first()
if session:
session.revoke(reason=reason)
logger.info(f"[SuperadminAuth] Session {session_id} revoked: {reason or 'No reason'}")
@@ -111,9 +117,11 @@ class SuperadminAuthService:
except_token: Optional token to keep (current session)
reason: Optional revocation reason
"""
query = SuperadminSession.query.filter_by(superadmin_id=superadmin_id)
query = Session.query.filter_by(
owner_type=SessionType.SUPERADMIN, owner_id=superadmin_id
)
if except_token:
query = query.filter(SuperadminSession.token != except_token)
query = query.filter(Session.token != except_token)
sessions = query.all()
for session in sessions:
+28
View File
@@ -52,6 +52,13 @@ class SessionStatus(str, Enum):
REVOKED = "revoked"
class SessionType(str, Enum):
"""Session owner type discriminator."""
USER = "user"
SUPERADMIN = "superadmin"
class AuditAction(str, Enum):
"""Audit log action types."""
@@ -154,6 +161,27 @@ class AuditAction(str, Enum):
DEPARTMENT_DELETED = "department.deleted"
DEPARTMENT_MEMBER_ADDED = "department.member.added"
DEPARTMENT_MEMBER_REMOVED = "department.member.removed"
DEPARTMENT_CERT_POLICY_UPDATED = "department.cert_policy.updated"
# Organization invite actions
ORG_INVITE_CANCELLED = "org.invite.cancelled"
# MFA reminder
ORG_MFA_REMINDER_SENT = "org.mfa_reminder.sent"
# API key actions
ORG_API_KEY_CREATED = "org.api_key.created"
ORG_API_KEY_UPDATED = "org.api_key.updated"
ORG_API_KEY_DELETED = "org.api_key.deleted"
# OIDC client actions
ORG_CLIENT_CREATED = "org.client.created"
ORG_CLIENT_UPDATED = "org.client.updated"
ORG_CLIENT_DEACTIVATED = "org.client.deactivated"
# Principal department link actions
PRINCIPAL_DEPARTMENT_LINKED = "principal.department.linked"
PRINCIPAL_DEPARTMENT_UNLINKED = "principal.department.unlinked"
class OIDCGrantType(str, Enum):