33 Commits

Author SHA1 Message Date
sangnn a3b230e65d ci: add ansible and CICD deployment
PR -> develop / Scan for secrets (Gitleaks) (pull_request) Failing after 4s
PR -> develop / Scan for CVEs (Trivy) (pull_request) Successful in 2s
2026-06-23 07:16:42 +00:00
coryHawkvelt a6d74d9316 fix: prevent ghost memberships from soft-deleted users 2026-06-10 05:30:24 +00:00
coryHawkvelt 05589ce442 cli: Add multi org support for issuing certs, add testing 2026-06-05 06:23:12 +00:00
coryHawkvelt f002f4e495 feat: expose ZT reconciliation drift metrics in job output 2026-06-02 04:32:55 +00:00
coryHawkvelt 66df4b6ab5 feat: add deactivation reason to session termination logs 2026-06-01 16:32:00 +00:00
coryHawkvelt ccd21ccde4 docs: document suspended membership reinstatement paths 2026-06-01 07:46:04 +00:00
coryHawkvelt 55f24ea9e5 feat: hide invite-only networks from non-admin users in listing 2026-05-30 06:40:49 +00:00
coryHawkvelt 2aad17f5e0 feat: add network-level kill switch endpoint 2026-05-30 06:32:26 +00:00
coryHawkvelt fed72f8bcd feat: add admin and user session listing endpoints with enriched device/network details 2026-05-29 05:30:51 +00:00
coryHawkvelt f869f6c06d feat: send suspension emails and enhanced audit logs for MFA non-compliance 2026-05-29 05:28:13 +00:00
coryHawkvelt 13767d3fa1 fix: add missing ExternalProviderConfig import to models package 2026-05-29 04:58:54 +00:00
coryHawkvelt cade827b63 feat: return human-friendly names for network members 2026-05-28 10:19:20 +00:00
coryHawkvelt 2c8160d78e Updated ZeroTier network membership flow and logic 2026-05-28 05:42:04 +00:00
coryHawkvelt 2342a1aab6 Added OIDC client CORS attributes 2026-05-19 15:15:47 +00:00
Ubuntu 78bae3c2bb Improvments to logging\auditing 2026-05-19 10:38:26 +00:00
HawkveltGiteaAdmin f856aa5aea Merge pull request #37 from CoryHawkless/oidc-uplift
OIDC uplift
2026-05-19 14:48:58 +09:30
Ubuntu 815084132f refactor: standardize audit logging for ISO27001 compliance 2026-05-14 05:59:49 +00:00
Ubuntu 417d462fb9 Resolved issue with incorrect method for recording ip_address and user_agent 2026-05-08 09:25:27 +00:00
Ubuntu 81a221bd2b refactor: consolidate login audit logging and add superadmin user audit endpoints 2026-05-08 06:26:32 +00:00
Ubuntu 6d794106be fixed app double loading 2026-05-07 21:20:25 +00:00
Ubuntu c6f36ba62c feat: add user and event filtering to organization activity endpoint 2026-05-07 20:45:44 +00:00
Ubuntu d100fdff3b feat: allow admins to bypass approval flow when joining networks 2026-05-07 20:04:08 +00:00
coryHawkvelt 803bf4f4f2 refactor: consolidate user and superadmin sessions into unified model 2026-04-28 20:54:15 +09:30
coryHawkvelt 5abbadff9a Improve auditing 2026-04-28 17:17:54 +09:30
coryHawkvelt 63a3109a82 oidc-client mk1 2026-04-27 02:44:32 +09:30
HawkveltGiteaAdmin 32d517ea08 Merge pull request #30 from jamesii-b/v1.01/stable
Feat: Implemented Known hosts via CLI & Fix:  Permissons for ssh-cert
2026-04-26 22:55:07 +08:00
HawkveltGiteaAdmin 5b799b186f Merge branch 'main' into v1.01/stable 2026-04-26 22:54:54 +08:00
HawkveltGiteaAdmin 5d94299aaa Merge pull request #34 from CoryHawkless/cory-wip-session
fix(cors): handle wildcard origin with credentials and add unit tests
2026-04-26 22:34:50 +08:00
HawkveltGiteaAdmin dfe584b60a Merge pull request #35 from CoryHawkless/migration-fix
Migration fix
2026-04-26 14:42:36 +08:00
coryHawkvelt adfeb1bd0f fix: remove redundant unique constraints on id columns from all migrations
Remove UniqueConstraint('id') from all create_table calls in the initial
migration (40 occurrences) and the bulk constraint additions from the
superadmin migration (43 create + 43 drop). These were redundant with
PrimaryKeyConstraint('id') which already guarantees uniqueness.

Also removes duplicate unique enforcement on superadmins.email and
superadmin_sessions.token (kept the unique indexes, dropped the
table-level UniqueConstraints).

Fixes the root cause in BaseModel by removing unique=True from the id
column definition, which was causing Alembic autogenerate to produce
these redundant constraints.

Renames idx_cert_audit_org to ix_certificate_audit_logs_organization_id
to follow Alembic naming conventions.
2026-04-26 06:41:33 +00:00
coryHawkvelt 0fb98b4b38 Migration fix 2026-04-26 06:22:05 +00:00
HawkveltGiteaAdmin 01c76ed172 Merge pull request #32 from CoryHawkless/cli-ui
Cli UI
2026-04-25 22:45:50 +08:00
JamesBhattarai 05cf3b3840 Feat: added --install-known-hosts & Fix: Permissons for ssh-cert
This allows users to copy the Host CA Pub key hosts directly into their ~/.ssh/known_hosts

Implemented chmod 600 for /tmp/ssh-cert (CERT_FILE_PATH)
2026-04-09 14:49:44 +05:45
121 changed files with 9667 additions and 1840 deletions
+8
View File
@@ -144,3 +144,11 @@ ZEROTIER_API_URL=
# OIDC_RATE_LIMIT_AUTHORIZE=10/minute # OIDC_RATE_LIMIT_AUTHORIZE=10/minute
# OIDC_RATE_LIMIT_TOKEN=20/minute # OIDC_RATE_LIMIT_TOKEN=20/minute
# OIDC_RATE_LIMIT_USERINFO=60/minute # OIDC_RATE_LIMIT_USERINFO=60/minute
# ═════════════════════════════════════════════════════════════════════════════
# GitHub Actions self-hosted runners (deploy/ansible/install-runner.yml)
# Per-project registration tokens. Short-lived (~1h) — mint fresh from each repo's
# Settings > Actions > Runners > New self-hosted runner. The Ansible playbook reads
# these as EXPORTED ENV VARS on the control node, not from this file:
# export RUNNER_TOKEN_GATEHOUSE_API=xxx
# export RUNNER_TOKEN_GATEHOUSE_UI=yyy
+58
View File
@@ -0,0 +1,58 @@
name: PR -> develop
on:
pull_request:
branches:
- main
- develop
env:
GITLEAKS_VERSION: "8.30.1"
jobs:
# ── 1. Secret scan ────────────────────────────────────────────────────────────
gitleaks:
name: Scan for secrets (Gitleaks)
runs-on: stage-gatehouse-api
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
if command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks already installed: $(gitleaks version)"
exit 0
fi
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar xz gitleaks
mv gitleaks /usr/local/bin/gitleaks
- name: Run secret scan
run: gitleaks detect --source . --exit-code 1 --redact --verbose --log-level debug
# ── 2. CVE scan ───────────────────────────────────────────────────────────────
trivy:
name: Scan for CVEs (Trivy)
runs-on: stage-gatehouse-api
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
command -v trivy >/dev/null 2>&1 || \
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
- name: Run filesystem scan
run: |
trivy fs \
--exit-code 1 \
--severity HIGH,CRITICAL \
--no-progress \
.
+91
View File
@@ -0,0 +1,91 @@
name: Push -> develop
on:
push:
branches:
- develop
jobs:
# ── 1. Build ──────────────────────────────────────────────────────────────────
build:
name: Build Docker images
runs-on: stage-gatehouse-api
outputs:
tag: ${{ steps.sha.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Set image tag
id: sha
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Build api image
run: |
docker build \
-t "gatehouse-api:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api:latest" \
.
- name: Build job image
run: |
docker build \
-f Dockerfile.job \
-t "gatehouse-api-job:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api-job:latest" \
.
- name: Scan api image for vulnerabilities (Trivy)
run: |
command -v trivy >/dev/null 2>&1 || \
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy image \
--exit-code 0 \
--severity HIGH,CRITICAL \
--no-progress \
"gatehouse-api:${{ steps.sha.outputs.tag }}"
- name: Scan job image for vulnerabilities (Trivy)
run: |
trivy image \
--exit-code 0 \
--severity HIGH,CRITICAL \
--no-progress \
"gatehouse-api-job:${{ steps.sha.outputs.tag }}"
# ── 2. Deploy ─────────────────────────────────────────────────────────────────
deploy:
name: Deploy
runs-on: stage-gatehouse-api
needs: build
env:
COMPOSE_DIR: /opt/gatehouse-api
steps:
- uses: actions/checkout@v4
- name: Deploy (rolling, zero-downtime)
run: |
cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml"
mkdir -p "${COMPOSE_DIR}/docker"
cp docker/nginx.conf "${COMPOSE_DIR}/docker/nginx.conf"
bash deploy/deploy.sh "${{ needs.build.outputs.tag }}"
# ── 3. Alert ──────────────────────────────────────────────────────────────────
alert:
name: Notify on result
runs-on: stage-gatehouse-api
needs: deploy
if: always()
steps:
- name: Send notification
run: |
STATUS="${{ needs.deploy.result }}"
echo "TODO: send alert — deploy status: ${STATUS}"
# curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \
# -H 'Content-Type: application/json' \
# -d "{\"text\": \"[gatehouse-api] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}"
+91
View File
@@ -0,0 +1,91 @@
name: Push -> main
on:
push:
branches:
- main
jobs:
# ── 1. Build ──────────────────────────────────────────────────────────────────
build:
name: Build Docker images
runs-on: prod-gatehouse-api
outputs:
tag: ${{ steps.sha.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Set image tag
id: sha
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Build api image
run: |
docker build \
-t "gatehouse-api:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api:latest" \
.
- name: Build job image
run: |
docker build \
-f Dockerfile.job \
-t "gatehouse-api-job:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api-job:latest" \
.
- name: Scan api image for vulnerabilities (Trivy)
run: |
command -v trivy >/dev/null 2>&1 || \
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy image \
--exit-code 0 \
--severity HIGH,CRITICAL \
--no-progress \
"gatehouse-api:${{ steps.sha.outputs.tag }}"
- name: Scan job image for vulnerabilities (Trivy)
run: |
trivy image \
--exit-code 0 \
--severity HIGH,CRITICAL \
--no-progress \
"gatehouse-api-job:${{ steps.sha.outputs.tag }}"
# ── 2. Deploy ─────────────────────────────────────────────────────────────────
deploy:
name: Deploy
runs-on: prod-gatehouse-api
needs: build
env:
COMPOSE_DIR: /opt/gatehouse-api
steps:
- uses: actions/checkout@v4
- name: Deploy (rolling, zero-downtime)
run: |
cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml"
mkdir -p "${COMPOSE_DIR}/docker"
cp docker/nginx.conf "${COMPOSE_DIR}/docker/nginx.conf"
bash deploy/deploy.sh "${{ needs.build.outputs.tag }}"
# ── 3. Alert ──────────────────────────────────────────────────────────────────
alert:
name: Notify on result
runs-on: prod-gatehouse-api
needs: deploy
if: always()
steps:
- name: Send notification
run: |
STATUS="${{ needs.deploy.result }}"
echo "TODO: send alert — deploy status: ${STATUS}"
# curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \
# -H 'Content-Type: application/json' \
# -d "{\"text\": \"[gatehouse-api] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}"
+54
View File
@@ -0,0 +1,54 @@
name: PR -> develop
on:
pull_request:
branches:
- main
- develop
env:
GITLEAKS_VERSION: "8.30.1"
jobs:
# ── 1. Secret scan ────────────────────────────────────────────────────────────
gitleaks:
name: Scan for secrets (Gitleaks)
runs-on: stage-secuird-runner
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar xz gitleaks
mv gitleaks /usr/local/bin/gitleaks
- name: Run secret scan
run: gitleaks detect --source . --exit-code 1 --redact --verbose --log-level debug
# ── 2. CVE scan ───────────────────────────────────────────────────────────────
trivy:
name: Scan for CVEs (Trivy)
runs-on: stage-secuird-runner
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
command -v trivy >/dev/null 2>&1 || \
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
- name: Run filesystem scan
run: |
trivy fs \
--exit-code 1 \
--severity HIGH,CRITICAL \
--no-progress \
.
+82
View File
@@ -0,0 +1,82 @@
name: Push -> develop
on:
push:
branches:
- develop
- ci/deploy
jobs:
# ── 1. Build ──────────────────────────────────────────────────────────────────
build:
name: Build Docker images
runs-on: stage-secuird-runner
outputs:
tag: ${{ steps.sha.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Set image tag
id: sha
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Build api image
run: |
docker build \
-t "gatehouse-api:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api:latest" \
.
- name: Build job image
run: |
docker build \
-f Dockerfile.job \
-t "gatehouse-api-job:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api-job:latest" \
.
- name: Scan api image for vulnerabilities (Trivy)
run: |
command -v trivy >/dev/null 2>&1 || \
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy image \
--exit-code 0 \
--severity HIGH,CRITICAL \
--no-progress \
"gatehouse-api:${{ steps.sha.outputs.tag }}"
# ── 2. Deploy ─────────────────────────────────────────────────────────────────
deploy:
name: Rolling deploy
runs-on: stage-secuird-runner
needs: build
env:
COMPOSE_DIR: /home/ubuntu/secuird/gatehouse-api
steps:
- uses: actions/checkout@v4
- name: Deploy (rolling restart)
run: |
cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml"
bash deploy/deploy.sh "${{ needs.build.outputs.tag }}"
# ── 3. Alert ──────────────────────────────────────────────────────────────────
alert:
name: Notify on result
runs-on: stage-secuird-runner
needs: deploy
if: always()
steps:
- name: Send notification
run: |
STATUS="${{ needs.deploy.result }}"
echo "TODO: send alert — deploy status: ${STATUS}"
# curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \
# -H 'Content-Type: application/json' \
# -d "{\"text\": \"[gatehouse-api] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}"
+81
View File
@@ -0,0 +1,81 @@
name: Push -> main
on:
push:
branches:
- main
jobs:
# ── 1. Build ──────────────────────────────────────────────────────────────────
build:
name: Build Docker images
runs-on: prod-secuird-runner
outputs:
tag: ${{ steps.sha.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Set image tag
id: sha
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Build api image
run: |
docker build \
-t "gatehouse-api:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api:latest" \
.
- name: Build job image
run: |
docker build \
-f Dockerfile.job \
-t "gatehouse-api-job:${{ steps.sha.outputs.tag }}" \
-t "gatehouse-api-job:latest" \
.
- name: Scan api image for vulnerabilities (Trivy)
run: |
command -v trivy >/dev/null 2>&1 || \
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy image \
--exit-code 0 \
--severity HIGH,CRITICAL \
--no-progress \
"gatehouse-api:${{ steps.sha.outputs.tag }}"
# ── 2. Deploy ─────────────────────────────────────────────────────────────────
deploy:
name: Rolling deploy
runs-on: prod-secuird-runner
needs: build
env:
COMPOSE_DIR: /home/ubuntu/secuird/gatehouse-api # TODO: confirm prod path
steps:
- uses: actions/checkout@v4
- name: Deploy (rolling restart)
run: |
cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml"
bash deploy/deploy.sh "${{ needs.build.outputs.tag }}"
# ── 3. Alert ──────────────────────────────────────────────────────────────────
alert:
name: Notify on result
runs-on: prod-secuird-runner
needs: deploy
if: always()
steps:
- name: Send notification
run: |
STATUS="${{ needs.deploy.result }}"
echo "TODO: send alert — deploy status: ${STATUS}"
# curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \
# -H 'Content-Type: application/json' \
# -d "{\"text\": \"[gatehouse-api] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}"
+5
View File
@@ -142,3 +142,8 @@ flask_session/
.opencode/ .opencode/
.swarm/ .swarm/
SWARM_PLAN.* SWARM_PLAN.*
# local backups / dumps / sessions
*.sql
*.dump
session-*.md
backups/
+17 -5
View File
@@ -19,19 +19,31 @@ COPY requirements/base.txt requirements/base.txt
COPY requirements/production.txt requirements/production.txt COPY requirements/production.txt requirements/production.txt
# Install dependencies # Install dependencies
RUN pip install --no-cache-dir --upgrade pip wheel && \ # Upgrade build tooling too: clears CVE-2026-24049 (wheel) and CVE-2026-23949 (jaraco.context)
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements/production.txt pip install --no-cache-dir -r requirements/production.txt
# Production stage # Production stage
FROM python:3.11-slim FROM python:3.11-slim
# Install runtime dependencies # Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ # apt-get upgrade pulls patched openssl/openssh/etc. so the image isn't pinned to
# whatever was current when the base layer was published.
# curl intentionally omitted: it was only used by HEALTHCHECK (now a stdlib Python
# check), and dropping it removes libcurl4t64 + libssh2 and their unfixed CVEs.
# NOTE: openssh-client retained for SSH CA workflows; drop it too if nothing shells
# out to ssh/scp (sshkey-tools signing is pure Python).
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 \ libpq5 \
curl \
openssh-client \ openssh-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Patch the base image's system-level build tooling that Trivy flags in
# /usr/local site-packages: wheel (CVE-2026-24049) and the jaraco.context
# (CVE-2026-23949) vendored by setuptools. Runs against system pip before the
# venv takes over PATH below.
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
# Create non-root user # Create non-root user
RUN groupadd --gid 1000 appgroup && \ RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
@@ -53,9 +65,9 @@ USER appuser
# Expose port # Expose port
EXPOSE 5000 EXPOSE 5000
# Health check # Health check (stdlib urllib — avoids shipping curl)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5000/api/health || exit 1 CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/api/health', timeout=5).getcode()==200 else 1)"]
# Run gunicorn with gevent workers # Run gunicorn with gevent workers
CMD ["gunicorn", "--bind", "0.0.0.0:5000", \ CMD ["gunicorn", "--bind", "0.0.0.0:5000", \
+6 -2
View File
@@ -12,15 +12,19 @@ WORKDIR /app
COPY requirements/base.txt requirements/base.txt COPY requirements/base.txt requirements/base.txt
COPY requirements/production.txt requirements/production.txt COPY requirements/production.txt requirements/production.txt
RUN pip install --no-cache-dir --upgrade pip wheel && \ RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements/production.txt pip install --no-cache-dir -r requirements/production.txt
FROM python:3.11-slim FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 \ libpq5 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Patch system-level wheel (CVE-2026-24049) + setuptools-vendored jaraco.context
# (CVE-2026-23949) that Trivy flags in /usr/local site-packages.
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
RUN groupadd --gid 1000 appgroup && \ RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
+59 -4
View File
@@ -369,7 +369,12 @@ def request_certificate(org_id=None):
json_result = response.json().get('data', response.json()) json_result = response.json().get('data', response.json())
with open(CERT_FILE_PATH, 'w') as f: with open(CERT_FILE_PATH, 'w') as f:
f.write(json_result['certificate']) f.write(json_result['certificate'])
try:
os.chmod(CERT_FILE_PATH, 0o600) os.chmod(CERT_FILE_PATH, 0o600)
except OSError:
pass
logger.info(f"Certificate signed successfully, located at {CERT_FILE_PATH}") logger.info(f"Certificate signed successfully, located at {CERT_FILE_PATH}")
logger.info(f"Valid for principals: {', '.join(json_result.get('principals', principals))}") logger.info(f"Valid for principals: {', '.join(json_result.get('principals', principals))}")
@@ -607,6 +612,51 @@ def checkCert():
logger.warning("Certificate is not valid, renewal required") logger.warning("Certificate is not valid, renewal required")
return 1 return 1
def install_known_hosts():
"""Fetch Host CA from the upstream server and install it into ~/.ssh/known_hosts."""
try:
response = requests.get(f"{SIGN_URL}/api/v1/ssh/ca/public-key?ca_type=host", headers=auth_headers())
if response.status_code != 200:
logger.error(f"Failed to fetch host CA public key: {response.status_code} - {response.text}")
exit(1)
ca_data = response.json().get('data', {})
public_key = ca_data.get('public_key', '').strip()
if not public_key:
logger.error("No public key found in the response.")
exit(1)
known_hosts_path = os.path.expanduser("~/.ssh/known_hosts")
ssh_dir = os.path.dirname(known_hosts_path)
if not os.path.exists(ssh_dir):
os.makedirs(ssh_dir, mode=0o700)
# Standard format for OpenSSH cert-authority
entry = f"@cert-authority * {public_key}\n"
# Check if already present
if os.path.exists(known_hosts_path):
with open(known_hosts_path, 'r') as f:
content = f.read()
if public_key in content:
logger.info("Host CA public key is already in ~/.ssh/known_hosts. No changes made.")
return
with open(known_hosts_path, 'a') as f:
f.write(entry)
try:
os.chmod(known_hosts_path, 0o600)
except OSError:
pass # May not have permission to chmod if owned by root, but let's try
logger.info(f"Successfully installed Host CA public key to {known_hosts_path} for all hosts (*)")
except Exception as e:
logger.error(f"Error during Host CA installation: {e}")
exit(1)
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Sign an SSH key via a web service') parser = argparse.ArgumentParser(description='Sign an SSH key via a web service')
parser.add_argument("-k", "--ssh-key", type=argparse.FileType('rb'), dest="sshkeyfile", help="Add an SSH Public Key to your user profile in gatehouse") parser.add_argument("-k", "--ssh-key", type=argparse.FileType('rb'), dest="sshkeyfile", help="Add an SSH Public Key to your user profile in gatehouse")
@@ -617,13 +667,14 @@ if __name__ == "__main__":
parser.add_argument("--clear-cache", action='store_true', default=False, help="Remove the cached authentication token") parser.add_argument("--clear-cache", action='store_true', default=False, help="Remove the cached authentication token")
parser.add_argument("--remove-key", nargs='?', const='', metavar='KEY_ID', help="Remove an SSH key from your profile. Omit KEY_ID to pick interactively.") parser.add_argument("--remove-key", nargs='?', const='', metavar='KEY_ID', help="Remove an SSH key from your profile. Omit KEY_ID to pick interactively.")
parser.add_argument("--list-keys", action='store_true', default=False, help="List SSH keys in your profile") parser.add_argument("--list-keys", action='store_true', default=False, help="List SSH keys in your profile")
parser.add_argument("--list-orgs", action='store_true', default=False, help="List all organizations you are a member of") parser.add_argument("--install-known-hosts", action='store_true', default=False, help="Fetch Host CA public key and install into ~/.ssh/known_hosts")
parser.add_argument("--org-id", metavar='ORG_ID', help="Specify organization ID for certificate signing (required if member of multiple orgs)") parser.add_argument("--list-orgs", action='store_true', default=False, help="List your organizations")
parser.add_argument("--org-id", type=str, help="Organization ID for cert signing (required when a member of multiple orgs)")
args = parser.parse_args() args = parser.parse_args()
if not (args.check_cert or args.request_cert or args.add_key or args.clear_cache if not (args.check_cert or args.request_cert or args.add_key or args.clear_cache
or args.remove_key is not None or args.list_keys or args.list_orgs): or args.remove_key is not None or args.list_keys or args.install_known_hosts or args.list_orgs):
parser.error("At least one of --check-cert, --request-cert, --add-key, --list-keys, --remove-key, --list-orgs, or --clear-cache must be provided.") parser.error("At least one of --check-cert, --request-cert, --add-key, --list-keys, --remove-key, --clear-cache, --list-orgs, or --install-known-hosts must be provided.")
# Retrieve SSH key from environment variables if not provided via CLI # Retrieve SSH key from environment variables if not provided via CLI
@@ -677,6 +728,10 @@ if __name__ == "__main__":
add_ssh_key(ssh_key_file) add_ssh_key(ssh_key_file)
exit(0) exit(0)
if args.install_known_hosts:
request_token()
install_known_hosts()
exit(0)
if args.request_cert: if args.request_cert:
request_token() request_token()
+1 -1
View File
@@ -77,7 +77,7 @@ class BaseConfig:
SESSION_REDIS = None # Will be set at app initialization SESSION_REDIS = None # Will be set at app initialization
# Rate Limiting # Rate Limiting
RATELIMIT_ENABLED = os.getenv("RATELIMIT_ENABLED", "True").lower() == "true" RATELIMIT_ENABLED = os.getenv("RATELIMIT_ENABLED", "False").lower() == "true"
RATELIMIT_STORAGE_URL = os.getenv("RATELIMIT_STORAGE_URL", "redis://localhost:6379/1") RATELIMIT_STORAGE_URL = os.getenv("RATELIMIT_STORAGE_URL", "redis://localhost:6379/1")
RATELIMIT_DEFAULT = "100/hour" RATELIMIT_DEFAULT = "100/hour"
+84
View File
@@ -0,0 +1,84 @@
# Gitea Actions self-hosted runners — Ansible
Provisions `act_runner` instances as **systemd services**. One host can run runners for
multiple projects (e.g. `gatehouse-api` + `gatehouse-ui`); each project gets its own
runner name, install dir, label set, repo URL, and registration token.
## Layout
```
ansible.cfg inventory + ssh defaults
inventory.ini stage / prod host groups (set ansible_host)
group_vars/all.yml pinned act_runner version + sha256, gitea_instance URL
host_vars/<host>.yml runner_env + per-project `runners` matrix
tasks/install_project.yml reads token, expands count -> N instances
tasks/install_one_runner.yml download/register/service one runner
install-runner.yml the playbook
```
## Runner naming
- name: `<host>-<project>-<env>-<N>` e.g. `stage01-gatehouse-api-stage-1`
- dir: `/home/gitea-runner/act-runner-<project>-<N>`
- labels: `project_spec.labels` (comma-separated, e.g. `self-hosted,linux,stage`)
- service: `gitea-runner-<project>-<N>.service`
## Before first run — pin act_runner version
1. Check [act_runner releases](https://gitea.com/gitea/act_runner/releases) for the latest stable version.
2. Download `act_runner-<version>-linux-amd64.sha256` from the release page.
3. Update `act_runner_version` and `act_runner_sha256` in `group_vars/all.yml`.
## Tokens
Registration tokens are **per-repo and expire ~1h after minting**. Exported as env
vars on the control node, named by each project's `token_env` in `host_vars`.
Mint each from the repo's **Settings → Actions → Runners → Create new runner token**,
then run the playbook within the hour.
## Run
```bash
cd deploy/ansible
# 1. Edit inventory.ini (set ansible_host / ansible_user) and host_vars/*.yml.
# 2. Fill in act_runner_version + act_runner_sha256 in group_vars/all.yml.
# 3. Export fresh tokens (leading space keeps them out of shell history):
export RUNNER_TOKEN_GATEHOUSE_API=xxx
export RUNNER_TOKEN_GATEHOUSE_UI=yyy
ansible-playbook --syntax-check install-runner.yml
ansible-playbook install-runner.yml --check --diff --limit stage # dry run
ansible-playbook install-runner.yml --limit stage # apply
```
## Verify
```bash
# on the host
systemctl list-units 'gitea-runner-*'
ls /home/gitea-runner/ # act-runner-gatehouse-api-1, ...
```
Each repo's **Settings → Actions → Runners** should list the runners as **Idle** with
the expected labels. Idempotent: re-running skips already-registered runners
(`creates: .runner` guards re-registration; binary download is checksum-gated).
## Migrating from GitHub Actions runners
Stop and remove the old runners on any existing host before re-running:
```bash
sudo su - github-runner -c "cd ~/actions-runner-*/; ./svc.sh stop && ./svc.sh uninstall"
sudo userdel -r github-runner # optional — removes home dir too
```
## Notes
- Bump `act_runner_version` + `act_runner_sha256` together in `group_vars/all.yml`.
- Labels in `host_vars/<host>.yml` must match the `runs-on:` values in workflow files.
- `deploy/deploy.sh` needs the runner user to have passwordless sudo for nginx reload —
add that sudoers drop-in separately (not yet automated here). Update the username from
`github-runner` to `gitea-runner` in that drop-in.
- `prod01.ansible_host` in `inventory.ini` is still `CHANGE_ME` — fill in before running prod.
+10
View File
@@ -0,0 +1,10 @@
[defaults]
inventory = inventory.ini
host_key_checking = False
retry_files_enabled = False
stdout_callback = default
result_format = yaml
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
+40
View File
@@ -0,0 +1,40 @@
---
# Shared constants for all runner hosts.
runner_user: gitea-runner
runner_home: /home/gitea-runner
# Base dir for app deploy targets. Each project deploys to <app_base_dir>/<project>
# (e.g. /opt/gatehouse-api), owned by the runner so the deploy job can write the
# compose file there. Must match COMPOSE_DIR in the project's workflow files.
app_base_dir: /opt
gitea_instance: "https://source.hawkless.id.au"
# Pinned act_runner release. Bump version + sha256 together.
# Check latest: https://gitea.com/gitea/act_runner/releases
# sha256 for act_runner-<version>-linux-amd64 is on the release page (act_runner-<version>-linux-amd64.sha256).
act_runner_version: "1.0.8"
act_runner_sha256: "027d726127bb67e191d57052fdb66e74ec7f76966f790a18727147fa2b8005e5"
act_runner_binary: "gitea-runner-{{ act_runner_version }}-linux-amd64"
act_runner_download_url: "https://gitea.com/gitea/runner/releases/download/v{{ act_runner_version }}/{{ act_runner_binary }}"
# Node.js major version installed on the host executor. JS actions
# (actions/checkout@v4, etc.) are run with `node`; without it act_runner
# fails with "Cannot find: node in PATH". Bump this to change versions.
node_major_version: "26"
# Security scanners pre-installed on the host so workflow steps use the local
# binary instead of writing to /usr/local/bin as the runner user ("Permission
# denied"). gitleaks_version must match GITLEAKS_VERSION in pr-security-check.yml.
trivy_version: "0.71.2"
gitleaks_version: "8.30.1"
# Docker CLI plugins. The engine ships without them, so `docker compose` (used by
# deploy.sh) and `docker buildx` are missing — deploy.sh then fails with exit 125.
# Installed as pinned binaries into the host cli-plugins dir; bump to upgrade.
docker_compose_version: "5.1.4"
docker_buildx_version: "0.35.0"
# Registration tokens come from env vars named by each project's `token_env`
# (e.g. RUNNER_TOKEN_GATEHOUSE_API). Export them on the control node before running.
# Mint from: Gitea repo → Settings → Actions → Runners → Create new runner token.
+15
View File
@@ -0,0 +1,15 @@
---
runner_env: prod
runners:
- project: gatehouse-api
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-api
labels: "self-hosted:host,linux:host,prod:host,prod-gatehouse-api:host"
token_env: RUNNER_TOKEN_GATEHOUSE_API
count: 1
- project: gatehouse-ui
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-ui
labels: "self-hosted:host,linux:host,prod:host"
token_env: RUNNER_TOKEN_GATEHOUSE_UI
count: 1
+19
View File
@@ -0,0 +1,19 @@
---
runner_env: stage
# One entry per project. A host runs runners for every project listed.
# labels: comma-separated "<name>:<executor>" pairs. Use :host for native execution,
# :docker://<image> for Docker. Must match runs-on: values in workflow files.
# token_env: env var name on the control node holding a fresh registration token (~1h TTL).
runners:
- project: gatehouse-api
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-api
labels: "self-hosted:host,linux:host,stage:host,stage-gatehouse-api:host"
token_env: RUNNER_TOKEN_GATEHOUSE_API
count: 1
- project: gatehouse-ui
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-ui
labels: "self-hosted:host,linux:host,stage:host,stage-gatehouse-ui:host"
token_env: RUNNER_TOKEN_GATEHOUSE_UI
count: 1
+160
View File
@@ -0,0 +1,160 @@
---
- name: Install Gitea Actions self-hosted runners
hosts: all
become: true
pre_tasks:
- name: Assert host defines a runners matrix
ansible.builtin.assert:
that:
- runners is defined
- runners | length > 0
- runner_env is defined
fail_msg: "Host {{ inventory_hostname }} is missing host_vars (runners / runner_env)."
tasks:
- name: Ensure runner service user exists
ansible.builtin.user:
name: "{{ runner_user }}"
shell: /bin/bash
create_home: true
home: "{{ runner_home }}"
# JS actions (actions/checkout@v4, etc.) execute with `node` on the host
# executor. Without it act_runner fails: "Cannot find: node in PATH".
# git is needed by checkout for its fetch step.
- name: Ensure git is present
ansible.builtin.apt:
name: git
state: present
update_cache: true
- name: Install Node.js {{ node_major_version }}.x (NodeSource)
block:
# Key is ASCII-armored, so store it as .asc — apt reads .gpg as binary
# and .asc as armored; a mismatch fails repo signature verification.
- name: Add NodeSource apt key
ansible.builtin.get_url:
url: https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key
dest: /usr/share/keyrings/nodesource.asc
mode: "0644"
- name: Add NodeSource apt repo
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/nodesource.asc] https://deb.nodesource.com/node_{{ node_major_version }}.x nodistro main"
filename: nodesource
- name: Install nodejs
ansible.builtin.apt:
name: nodejs
state: present
update_cache: true
# Security scanners used by the CI workflows. Pre-installing them (as root)
# means the workflow steps find them on PATH and skip their runtime install,
# which would otherwise fail writing to /usr/local/bin as the runner user.
- name: Check installed Trivy version
ansible.builtin.command: trivy --version
register: trivy_check
changed_when: false
failed_when: false
- name: Install Trivy {{ trivy_version }}
ansible.builtin.shell: |
set -o pipefail
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin v{{ trivy_version }}
args:
executable: /bin/bash
when: trivy_version not in (trivy_check.stdout | default(''))
- name: Check installed Gitleaks version
ansible.builtin.command: gitleaks version
register: gitleaks_check
changed_when: false
failed_when: false
- name: Install Gitleaks {{ gitleaks_version }}
ansible.builtin.unarchive:
src: "https://github.com/gitleaks/gitleaks/releases/download/v{{ gitleaks_version }}/gitleaks_{{ gitleaks_version }}_linux_x64.tar.gz"
dest: /usr/local/bin
remote_src: true
include:
- gitleaks
mode: "0755"
when: gitleaks_version not in (gitleaks_check.stdout | default(''))
# The Docker engine ships without CLI plugins, so `docker compose` (used by
# deploy.sh) and `docker buildx` are absent. Install them host-wide; this
# path is searched by both docker.io and docker-ce CLIs.
- name: Ensure Docker CLI plugins dir exists
ansible.builtin.file:
path: /usr/libexec/docker/cli-plugins
state: directory
mode: "0755"
- name: Check installed Docker Compose plugin version
ansible.builtin.command: docker compose version --short
register: compose_check
changed_when: false
failed_when: false
- name: Install Docker Compose v2 plugin {{ docker_compose_version }}
ansible.builtin.get_url:
url: "https://github.com/docker/compose/releases/download/v{{ docker_compose_version }}/docker-compose-linux-x86_64"
dest: /usr/libexec/docker/cli-plugins/docker-compose
mode: "0755"
force: true
when: docker_compose_version not in (compose_check.stdout | default(''))
- name: Check installed Docker Buildx plugin version
ansible.builtin.command: docker buildx version
register: buildx_check
changed_when: false
failed_when: false
- name: Install Docker Buildx plugin {{ docker_buildx_version }}
ansible.builtin.get_url:
url: "https://github.com/docker/buildx/releases/download/v{{ docker_buildx_version }}/buildx-v{{ docker_buildx_version }}.linux-amd64"
dest: /usr/libexec/docker/cli-plugins/docker-buildx
mode: "0755"
force: true
when: docker_buildx_version not in (buildx_check.stdout | default(''))
# Deploy target for each project's compose stack. Owned by the runner so the
# deploy job can `cp docker-compose.yml` here; the host-managed .env lives
# here too. Basename matches the compose project name, preserving volumes.
- name: Ensure app deploy dir exists for each project
ansible.builtin.file:
path: "{{ app_base_dir }}/{{ item.project }}"
state: directory
owner: "{{ runner_user }}"
group: "{{ runner_user }}"
mode: "0755"
loop: "{{ runners }}"
loop_control:
label: "{{ item.project }}"
- name: Install runners for each project
ansible.builtin.include_tasks: tasks/install_project.yml
loop: "{{ runners }}"
loop_control:
loop_var: project_spec
label: "{{ project_spec.project }}"
# The build job runs `docker build` on the host, talking to the daemon via
# /var/run/docker.sock. Without docker group membership the runner user gets
# "permission denied ... unix:///var/run/docker.sock".
- name: Add runner user to the docker group
ansible.builtin.user:
name: "{{ runner_user }}"
groups: docker
append: true
register: runner_docker_group
# Group membership is only read at process start, so already-running runner
# services must be restarted to gain socket access.
- name: Restart runner services to apply docker group membership
ansible.builtin.shell: "systemctl restart 'gitea-runner-*.service'"
when: runner_docker_group is changed
changed_when: true
+9
View File
@@ -0,0 +1,9 @@
# Self-hosted GitHub Actions runner hosts.
# Per-project runner matrix lives in host_vars/<host>.yml.
# Set ansible_host (and ansible_user if not root) to real values.
[stage]
stage01 ansible_host=172.25.25.209 ansible_user=ubuntu
[prod]
prod01 ansible_host=CHANGE_ME ansible_user=ubuntu
@@ -0,0 +1,68 @@
---
# Installs + registers + services a single act_runner instance.
# Inputs: project_spec (dict), idx (int), project_token (str).
- name: Set per-runner facts
ansible.builtin.set_fact:
runner_name: "{{ inventory_hostname }}-{{ project_spec.project }}-{{ runner_env }}-{{ idx }}"
runner_dir: "{{ runner_home }}/act-runner-{{ project_spec.project }}-{{ idx }}"
svc_name: "gitea-runner-{{ project_spec.project }}-{{ idx }}"
- name: "Create runner dir {{ runner_dir }}"
ansible.builtin.file:
path: "{{ runner_dir }}"
state: directory
owner: "{{ runner_user }}"
group: "{{ runner_user }}"
mode: "0755"
- name: Download act_runner binary (sha256 verified)
ansible.builtin.get_url:
url: "{{ act_runner_download_url }}"
dest: "{{ runner_dir }}/gitea-runner"
checksum: "sha256:{{ act_runner_sha256 }}"
owner: "{{ runner_user }}"
group: "{{ runner_user }}"
mode: "0755"
- name: "Register runner {{ runner_name }}"
ansible.builtin.command:
cmd: >-
./gitea-runner register
--no-interactive
--instance {{ gitea_instance }}
--token {{ project_token }}
--name {{ runner_name }}
--labels {{ project_spec.labels }}
chdir: "{{ runner_dir }}"
creates: "{{ runner_dir }}/.runner"
become_user: "{{ runner_user }}"
- name: "Write systemd unit for {{ svc_name }}"
ansible.builtin.copy:
dest: "/etc/systemd/system/{{ svc_name }}.service"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Gitea Actions Runner ({{ runner_name }})
After=network.target
[Service]
Type=simple
User={{ runner_user }}
WorkingDirectory={{ runner_dir }}
ExecStart={{ runner_dir }}/gitea-runner daemon
Restart=always
RestartSec=5s
Environment=HOME={{ runner_home }}
[Install]
WantedBy=multi-user.target
- name: "Enable + start {{ svc_name }}"
ansible.builtin.systemd:
name: "{{ svc_name }}"
enabled: true
state: started
daemon_reload: true
+20
View File
@@ -0,0 +1,20 @@
---
# Expands one project entry into `count` runner instances.
- name: "Read registration token for {{ project_spec.project }} from env var"
ansible.builtin.set_fact:
project_token: "{{ lookup('ansible.builtin.env', project_spec.token_env) }}"
- name: "Warn and skip {{ project_spec.project }} — token missing"
ansible.builtin.debug:
msg: >-
Skipping {{ project_spec.project }}: env var {{ project_spec.token_env }} is empty/unset.
Export a fresh registration token (Gitea repo → Settings → Actions → Runners → Create new runner token).
when: project_token | length == 0
- name: "Install {{ project_spec.count }} runner(s) for {{ project_spec.project }}"
ansible.builtin.include_tasks: install_one_runner.yml
loop: "{{ range(1, project_spec.count | int + 1) | list }}"
loop_control:
loop_var: idx
label: "{{ project_spec.project }}-{{ idx }}"
when: project_token | length > 0
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Rolling deploy for the dockerised stack (api1 + api2 behind a containerised
# nginx). api containers are recreated one at a time; while one restarts, nginx
# routes to the other via the upstream's passive failover (max_fails/fail_timeout
# + proxy_next_upstream), so users see effectively no downtime. No host nginx and
# no sudo required — everything goes through `docker compose`.
#
# Usage:
# ./deploy.sh <image-tag> e.g. ./deploy.sh a1b2c3d
#
# Overridable via env vars (defaults below are gatehouse-api):
# IMAGE_NAME docker image name
# SVC1/SVC2 api compose service names
# SVC1_PORT host port for SVC1
# SVC2_PORT host port for SVC2
# JOB_SVCS space-separated job service names
# HEALTH_PATH HTTP path for health check
# COMPOSE_DIR directory with docker-compose.yml, docker/nginx.conf and .env
set -euo pipefail
TAG="${1:?Usage: deploy.sh <image-tag> (e.g. deploy.sh a1b2c3d)}"
IMAGE_NAME="${IMAGE_NAME:-gatehouse-api}"
COMPOSE_DIR="${COMPOSE_DIR:-/opt/gatehouse-api}"
SVC1="${SVC1:-api1}"
SVC2="${SVC2:-api2}"
SVC1_PORT="${SVC1_PORT:-5000}"
SVC2_PORT="${SVC2_PORT:-5001}"
JOB_SVCS="${JOB_SVCS:-zerotier-reconciler mfa-compliance}"
HEALTH_PATH="${HEALTH_PATH:-/api/health}"
HEALTH_RETRIES=18 # 18 × 5 s = 90 s max
HEALTH_INTERVAL=5
export IMAGE_TAG="${TAG}"
# ── helpers ───────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*"; }
die() { log "ERROR: $*" >&2; exit 1; }
step() { log ""; log "── $* ──"; }
health_check() {
local port=$1 label=$2 attempt=0
log "Waiting for ${label} on :${port}${HEALTH_PATH} ..."
while (( attempt < HEALTH_RETRIES )); do
if curl -sf "http://127.0.0.1:${port}${HEALTH_PATH}" -o /dev/null; then
log "${label} healthy"
return 0
fi
attempt=$(( attempt + 1 ))
log " [${attempt}/${HEALTH_RETRIES}] not ready, retrying in ${HEALTH_INTERVAL}s"
sleep "${HEALTH_INTERVAL}"
done
log "ERROR: ${label} failed health check after $((HEALTH_RETRIES * HEALTH_INTERVAL))s"
return 1
}
get_service_tag() {
docker compose ps -q "$1" 2>/dev/null \
| xargs -r docker inspect --format '{{.Config.Image}}' 2>/dev/null \
| cut -d: -f2
}
# Recreate one api service on the new tag, then health-check it; roll back to the
# previous tag on failure. The peer api keeps serving traffic throughout.
roll_api() {
local service=$1 port=$2 old_tag
step "${service}${TAG}"
old_tag=$(get_service_tag "${service}")
docker compose up -d --no-deps --force-recreate "${service}"
if ! health_check "${port}" "${service}"; then
if [[ -z "${old_tag}" ]]; then
die "Deploy aborted — ${service} failed health check, no previous tag to roll back to"
fi
log "Rolling back ${service} to ${old_tag}..."
IMAGE_TAG="${old_tag}" docker compose up -d --no-deps --force-recreate "${service}"
die "Deploy aborted — ${service} rolled back to ${old_tag}"
fi
}
# ── pre-flight ────────────────────────────────────────────────────────────────
cd "${COMPOSE_DIR}"
pwd; ls -la
log "Deploying ${IMAGE_NAME}:${TAG}"
docker image inspect "${IMAGE_NAME}:${TAG}" > /dev/null 2>&1 \
|| die "Image ${IMAGE_NAME}:${TAG} not found locally — build it first."
# Ensure backing services are up before rolling the api (idempotent; also brings
# the stack up cleanly on a first-ever deploy).
step "backing services (db, redis)"
docker compose up -d db redis
# ── roll api containers one at a time ─────────────────────────────────────────
roll_api "${SVC1}" "${SVC1_PORT}"
roll_api "${SVC2}" "${SVC2_PORT}"
# ── nginx + job workers ───────────────────────────────────────────────────────
# Bring nginx up (created on first deploy) and refresh job workers to the new
# tag. api1/api2 are already at the desired tag, so they are left untouched.
step "nginx + job workers → ${TAG}"
docker compose up -d --remove-orphans
# Apply any nginx.conf change without dropping connections (bind-mounted config
# is not re-read on `up`). Skipped if nginx isn't running yet.
if [[ -n "$(docker compose ps -q nginx 2>/dev/null)" ]]; then
if docker compose exec -T nginx nginx -t 2>/dev/null; then
docker compose exec -T nginx nginx -s reload && log "nginx: reloaded"
else
log "WARNING: nginx config test failed — left running with previous config"
fi
fi
# ── done ──────────────────────────────────────────────────────────────────────
log ""
log "Deploy complete ✓ ${IMAGE_NAME}:${TAG}"
+17
View File
@@ -0,0 +1,17 @@
version: '3.8'
services:
api:
environment:
- FLASK_ENV=development
- FLASK_DEBUG=1
volumes:
- .:/app
command: >
flask run --host=0.0.0.0 --port=5000 --reload
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
+29 -14
View File
@@ -1,14 +1,12 @@
version: '3.8' version: '3.8'
services: services:
api: api1:
build: image: gatehouse-api:${IMAGE_TAG:-latest}
context: .
dockerfile: Dockerfile
env_file: env_file:
- .env - .env
ports: ports:
- "${API_PORT:-5000}:5000" - "0.0.0.0:5000:5000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -18,7 +16,28 @@ services:
- authy2-network - authy2-network
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"] test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
api2:
image: gatehouse-api:${IMAGE_TAG:-latest}
env_file:
- .env
ports:
- "0.0.0.0:5001:5000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- authy2-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -66,9 +85,9 @@ services:
- ./docker/nginx.conf:/etc/nginx/nginx.conf:ro - ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
ports: ports:
- "${HTTP_PORT:-80}:80" - "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
depends_on: depends_on:
- api - api1
- api2
networks: networks:
- authy2-network - authy2-network
restart: unless-stopped restart: unless-stopped
@@ -79,9 +98,7 @@ services:
retries: 3 retries: 3
zerotier-reconciler: zerotier-reconciler:
build: image: gatehouse-api-job:${IMAGE_TAG:-latest}
context: .
dockerfile: Dockerfile.job
env_file: env_file:
- .env - .env
environment: environment:
@@ -97,9 +114,7 @@ services:
restart: unless-stopped restart: unless-stopped
mfa-compliance: mfa-compliance:
build: image: gatehouse-api-job:${IMAGE_TAG:-latest}
context: .
dockerfile: Dockerfile.job
env_file: env_file:
- .env - .env
environment: environment:
+2 -1
View File
@@ -34,7 +34,8 @@ http {
application/xml application/xml+rss text/javascript application/x-javascript; application/xml application/xml+rss text/javascript application/x-javascript;
upstream api { upstream api {
server api:5000; server api1:5000 max_fails=2 fail_timeout=10s;
server api2:5000 max_fails=2 fail_timeout=10s;
} }
server { server {
+103
View File
@@ -0,0 +1,103 @@
# CLI Integration Tests
This document describes the integration tests for `client/gatehouse-cli.py`.
## Approach
The CLI is tested as a **real subprocess** (not mocked functions) against a **real Flask HTTP server** with an in-memory/file-based SQLite database. Each test is fully self-contained, using UUID-based names to avoid collisions on the shared database.
Key decisions:
- **Subprocess + real server**: The CLI is invoked via `subprocess.run([sys.executable, "client/gatehouse-cli.py", ...])` with `SIGN_URL` and `HOME` env vars pointing to the test server and an isolated temp directory. This catches real-world issues like argparse mismatches, import errors, and API contract drift.
- **Module-scoped server**: A single Flask server (random port, `sqlite:///<tempfile>`) is started once per module via a `cli_server` fixture and shared across all tests. Data isolation is achieved by UUID-scoped names rather than per-test databases.
- **Real crypto**: Certificate-signing tests use real Ed25519 keypairs generated by `ssh-keygen`. The `sshkey_tools` library validates the public key, so fake-looking keys (random hex / padding) are rejected by the server.
- **DB-level bypasses**: For cert-signing tests, `_mark_key_verified()` sets `verified=True` directly in the database (same pattern as `test_ssh_workflows.py`), avoiding the complex `ssh-keygen -Y sign` flow.
## Output Handling
The CLI emits all messages via the `coloredlogs` library, which writes to **stderr**, not stdout. Occasional `print()` calls go to stdout. The `_output()` helper combines both streams:
```python
def _output(result: subprocess.CompletedProcess) -> str:
return result.stdout + result.stderr
```
All text assertions use `in _output(result)`.
## Fixtures
| Fixture | Scope | Description |
|---------|-------|-------------|
| `cli_server` | module | Flask server on `127.0.0.1` with random port; yields `(server_url, app)` |
| `home_dir` | function | Isolated temp directory for `~/.gatehouse` and `~/.ssh` |
## Test Inventory (10 tests)
### 1. `test_cli_add_key`
**Flags:** `-a -k <pubkey>`
Generates a real Ed25519 keypair with `ssh-keygen`, registers a user, caches the token, then runs the CLI to add the key. Verifies the key-and-verify flow produces an "added successfully" message. Skips if `ssh-keygen` is not available.
### 2. `test_cli_list_keys`
**Flags:** `--list-keys`
Adds two fake SSH keys via the API, then runs the CLI to list them. Asserts both key ID prefixes appear in the output.
### 3. `test_cli_remove_key`
**Flags:** `--remove-key <id>`
Adds a key via the API, then runs the CLI to delete it by ID. Asserts "removed successfully" in the output.
### 4. `test_cli_list_orgs`
**Flags:** `--list-orgs`
Creates two organizations where the test user is an owner, then runs the CLI to list them. Asserts both org names appear in the output.
### 5. `test_cli_request_cert_single_org`
**Flags:** `-r`
Sets up a single organization with a user-type CA, a principal ("deploy"), a principal membership for the user, and a verified real SSH key. Runs the CLI to request a certificate. Asserts "Certificate signed successfully" and the principal name appear in the output.
### 6. `test_cli_request_cert_multi_org_no_org`
**Flags:** `-r` (with multiple orgs)
Creates two organizations, each with a CA and one with a principal membership. Runs `-r` without `--org-id`. Asserts exit code 1 and a "multiple organizations" error message.
### 7. `test_cli_request_cert_multi_org_with_org`
**Flags:** `-r --org-id <id>` (with multiple orgs)
Same setup as #6 but passes `--org-id` to disambiguate. Asserts exit code 0 and "Certificate signed successfully".
### 8. `test_cli_install_known_hosts`
**Flags:** `--install-known-hosts`
Creates a host-type CA, then runs the CLI to install the Host CA public key. Asserts the success message and verifies the public key was written to `~/.ssh/known_hosts`.
### 9. `test_cli_clear_cache`
**Flags:** `--clear-cache`
Writes a dummy token cache file, then runs the CLI to clear it. Asserts the cache file is deleted and "Cached token removed" appears in output. The server URL is irrelevant (no network calls).
### 10. `test_cli_check_cert`
**Flags:** `-c`
Runs the CLI to check for a certificate file that does not exist (no prior setup needed). Asserts exit code 1 and "Certificate does not exist" in output. The server URL is irrelevant (no network calls).
## Running
```bash
# Full suite
pytest tests/integration/test_cli.py -v
# Single test
pytest tests/integration/test_cli.py::TestCLI::test_cli_request_cert_single_org -xvs
# Without coverage noise
pytest tests/integration/test_cli.py --no-cov
```
## Notes
- The `_gen_pubkey()` helper generates fake-looking keys (random hex + base64 padding) that are accepted by the API for storage but **rejected** by `sshkey_tools.PublicKey.from_string()` during cert signing. Use `_real_pubkey()` (which invokes actual `ssh-keygen`) for tests that need cryptographically valid keys.
- `_add_principal_member()` must be called whenever a user needs access to a principal. Even org owners are not automatically members — the CLI's `fetch_my_principals()` reads `my_principals`, not `all_principals`.
- The `cli_server` fixture creates tables once (`db.create_all()`) and reuses the database file across all tests. UUID-suffixed names (via `_email()`, `uuid.uuid4().hex`) prevent collisions.
+240
View File
@@ -0,0 +1,240 @@
# Per-Client CORS Origins for OIDC Endpoints
## Overview
Gatehouse OIDC now supports **per-client CORS origins**. This allows each OIDC client to declare which browser origins are permitted to make cross-origin requests to OIDC endpoints (`/oidc/token`, `/oidc/revoke`, `/oidc/userinfo`, `/oidc/introspect`).
Previously, CORS was controlled by a single server-wide `CORS_ORIGINS` environment variable. If your SPA's origin wasn't in that list, the browser would block requests to OIDC endpoints — even if your OIDC client was properly configured.
## How It Works
### The Problem
When a browser-based SPA (e.g., running at `http://localhost:8080`) exchanges an authorization code for tokens, it makes a POST request to `/oidc/token`. The browser sends a preflight OPTIONS request first, and the server must respond with CORS headers allowing the SPA's origin.
Previously, if `http://localhost:8080` wasn't in the server's `CORS_ORIGINS` env var, the preflight would fail and the SPA couldn't get tokens.
### The Solution
Each OIDC client can now declare its own `allowed_cors_origins`. When a request hits an OIDC endpoint, the server checks the client's CORS configuration first, then falls back to the global config.
## Configuration
### Setting CORS Origins on an OIDC Client
When creating or updating an OIDC client, set the `allowed_cors_origins` field:
```json
{
"name": "My SPA",
"client_id": "oidc_myapp",
"redirect_uris": ["http://localhost:8080/callback", "https://app.example.com/callback"],
"allowed_cors_origins": ["http://localhost:8080", "https://app.example.com"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scopes": ["openid", "profile", "email"]
}
```
### Auto-Derive from Redirect URIs
Set `allowed_cors_origins` to `["+"]` to automatically derive CORS origins from the client's `redirect_uris`. The server extracts the scheme, hostname, and port from each redirect URI.
```json
{
"redirect_uris": ["http://localhost:8080/callback", "https://app.example.com/callback"],
"allowed_cors_origins": ["+"]
}
```
This is equivalent to:
```json
{
"allowed_cors_origins": ["http://localhost:8080", "https://app.example.com"]
}
```
### Use Global Config (Default)
Set `allowed_cors_origins` to `null` (or omit it) to use the server's global `CORS_ORIGINS` config. This is the default behavior for existing clients.
```json
{
"allowed_cors_origins": null
}
```
### Allow All Origins (Not Recommended)
Set `allowed_cors_origins` to `["*"]` to allow any origin. **This is not recommended for production.**
```json
{
"allowed_cors_origins": ["*"]
}
```
## Affected Endpoints
The following OIDC endpoints support per-client CORS:
| Endpoint | Method | How Client is Identified |
|---|---|---|
| `/oidc/token` | POST | `client_id` in request body or Basic Auth header |
| `/oidc/revoke` | POST | `client_id` in request body or Basic Auth header |
| `/oidc/introspect` | POST | `client_id` in request body or Basic Auth header |
| `/oidc/userinfo` | GET/POST | `client_id` extracted from Bearer token |
## SPA Integration Guide
### Step 1: Register Your OIDC Client
Register your SPA as an OIDC client with the correct redirect URIs and CORS origins:
```json
{
"name": "My React App",
"redirect_uris": ["http://localhost:3000/callback"],
"allowed_cors_origins": ["http://localhost:3000"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scopes": ["openid", "profile", "email"],
"is_confidential": false,
"require_pkce": true
}
```
### Step 2: Use PKCE (Required for Public Clients)
Gatehouse requires PKCE for public clients. Generate a code verifier and challenge before redirecting to the authorize endpoint:
```javascript
// Generate PKCE
const codeVerifier = generateRandomString(128);
const codeChallenge = await sha256(codeVerifier);
const state = generateRandomString(32);
// Store verifier for later
sessionStorage.setItem('pkce_verifier', codeVerifier);
// Redirect to authorize
const authUrl = new URL('https://api.example.com/api/v1/oidc/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'oidc_myapp');
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
```
### Step 3: Exchange Code for Tokens
After the user authenticates and is redirected back to your callback page, exchange the authorization code for tokens:
```javascript
// Extract code from URL
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
// Verify state matches
if (state !== sessionStorage.getItem('pkce_state')) {
throw new Error('State mismatch');
}
// Exchange code for tokens
const response = await fetch('https://api.example.com/api/v1/oidc/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'http://localhost:3000/callback',
client_id: 'oidc_myapp',
code_verifier: sessionStorage.getItem('pkce_verifier'),
}),
});
const tokens = await response.json();
// tokens.access_token, tokens.id_token, tokens.refresh_token
```
The server will return CORS headers because `http://localhost:3000` is in the client's `allowed_cors_origins`.
### Step 4: Refresh Tokens
```javascript
const response = await fetch('https://api.example.com/api/v1/oidc/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedRefreshToken,
client_id: 'oidc_myapp',
}),
});
```
### Step 5: Call UserInfo
```javascript
const response = await fetch('https://api.example.com/api/v1/oidc/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const userInfo = await response.json();
```
## Troubleshooting
### "CORS error" when exchanging code for tokens
**Cause**: Your SPA's origin is not in the client's `allowed_cors_origins` or the server's global `CORS_ORIGINS`.
**Fix**: Add your SPA's origin to the client's `allowed_cors_origins`:
```json
{
"allowed_cors_origins": ["http://localhost:3000"]
}
```
### "CORS error" on preflight OPTIONS request
**Cause**: The preflight request doesn't carry client credentials, so the server can't identify which client to check CORS origins for. It falls back to the global `CORS_ORIGINS`.
**Fix**: Either add your origin to the global `CORS_ORIGINS` env var, or ensure the actual POST request (after preflight) includes the `client_id` in the request body.
### CORS works for `/oidc/token` but not `/oidc/userinfo`
**Cause**: The userinfo endpoint identifies the client from the Bearer token. If the token doesn't contain a `client_id` claim, the server falls back to global config.
**Fix**: Ensure your access tokens include the `client_id` claim (this is the default behavior).
## API Reference
### OIDCClient Fields
| Field | Type | Description |
|---|---|---|
| `allowed_cors_origins` | `string[]` or `null` | List of allowed browser origins. `null` = use global config. `["+"]` = auto-derive from redirect URIs. `["*"]` = allow all (not recommended). |
### CORS Headers Returned
When a request's origin matches the client's allowed origins:
```
Access-Control-Allow-Origin: <request-origin>
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Request-ID, Cache-Control, Pragma, X-WebAuthn-Session-Token
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
```
+165
View File
@@ -0,0 +1,165 @@
# ZeroTier Device Membership
## Overview
This document covers the ZeroTier device membership model — how devices are registered, approved, activated, and deactivated on ZeroTier networks. It explains the schema design, the distinction between `active` and `approved`, the user activation flow, and audit coverage.
## Schema Design
The core model is `NetworkAccessRequest` (table: `network_access_requests`). It replaces the legacy two-table approach (`UserNetworkApproval` + `DeviceNetworkMembership`) with a single per-device, per-network row.
### `network_access_requests` table
| Column | Type | Purpose |
|---|---|---|
| `id` | UUID | Primary key |
| `organization_id` | FK -> organizations | Org scope |
| `user_id` | FK -> users | Requesting user |
| `device_id` | FK -> devices | Target device |
| `portal_network_id` | FK -> portal_networks | Target network |
| `granted_by_user_id` | FK -> users (nullable) | Approving manager |
| `grant_type` | enum | `requested` or `assigned` |
| `status` | enum | `pending`, `approved`, `rejected`, `revoked`, `suspended` |
| `active` | boolean | Currently authorized on controller? |
| `justification` | text | User's reason for requesting |
| `join_seen` | boolean | Controller observed the device join |
| `deleted_at` | timestamp (nullable) | Soft delete support |
**Unique constraint:** `(user_id, device_id, portal_network_id, deleted_at)` — ensures exactly one active record per device per network.
### Supporting models
| Model | Table | Purpose |
|---|---|---|
| `Device` | `devices` | A user-registered ZeroTier node (10-char node ID) |
| `PortalNetwork` | `portal_networks` | A managed ZeroTier network (scoped to org) |
| `ActivationSession` | `activation_sessions` | Temporary authorization window (TTL-based) |
| `ZeroTierMembership` | `zerotier_memberships` | Cache of controller-side state |
| `KillSwitchEvent` | `kill_switch_events` | Append-only audit of kill switch actions |
### Entity relationships
```
Device (1) ──> (N) NetworkAccessRequest (N) <── (1) PortalNetwork
(1 or 0)
ActivationSession
(1 or 0)
ZeroTierMembership
```
## `active` vs `approved` — the key distinction
These are orthogonal concepts:
| Concept | Represents | Set by | Persists across sessions? |
|---|---|---|---|
| **`status = approved`** | Administrative permission to use the network | Manager approval | Yes — once approved, stays approved until revoked |
| **`active = True`** | Device is currently authorized on the ZT controller | User activation | No — toggled on/off per session |
A request can be in any of these states:
| `status` | `active` | Meaning |
|---|---|---|
| `approved` | `false` | Manager said yes, but user hasn't activated yet |
| `approved` | `true` | Manager said yes, device is actively connected |
| `pending` | `false` | Awaiting manager decision |
| `rejected` / `revoked` / `suspended` | `false` | Access denied or removed |
The `active` flag is **not** a persistent grant — it's a run-time operational state tied to an `ActivationSession` with a finite TTL (default 8 hours).
## User activation flow — "turning on" ZeroTier
```
User API Service ZT Controller
│ │ │ │
│ POST /orgs/<id>/approvals │ │ │
├───────────────────────────>│ request_access() │ │
│ ├──> creates NetworkAccessRequest (status=PENDING) │
│ ├──> _ensure_zerotier_member() │
│ │ └──> provisions member (de-authorized) ────────────────>│
│ │ │ │
│ <── 201 { status: pending } │ │
│ │ │ │
│ [Admin approves] │ │ │
│ POST /orgs/<id>/approvals │ │ │
│ /<id>/approve │ │ │
├───────────────────────────>│ approve_request() │ │
│ ├──> sets status=APPROVED │ │
│ <── 200 { status: approved } │ │
│ │ │ │
│ POST /orgs/<id>/memberships/<id>/activate │ │
│ ─────────────────────────>│ activate_request() │ │
│ "turn on ZeroTier" ├──> creates ActivationSession (TTL=8h) │
│ ├──> sets request.active=True │ │
│ ├──> _authorize_in_zerotier() │ │
│ │ └──> authorizes member ───────────────────────────────>│
│ │ │ │
│ <── 200 { active: true, session: {...} } │ │
```
### Endpoints
| Method | Path | Action |
|---|---|---|
| `POST` | `/organizations/<id>/devices` | Register a device (prerequisite) |
| `POST` | `/organizations/<id>/approvals` | Request network access |
| `POST` | `/organizations/<id>/approvals/<id>/approve` | Admin approves |
| `POST` | `/organizations/<id>/memberships/<id>/activate` | **Turn on ZeroTier** |
| `POST` | `/organizations/<id>/memberships/activate-all` | Bulk-activate all approved |
| `POST` | `/organizations/<id>/memberships/<id>/deactivate` | Turn off ZeroTier |
| `POST` | `/organizations/<id>/devices/<id>/join-network/<id>` | Direct join (open networks) |
| `POST` | `/organizations/<id>/kill-switch` | Emergency deactivation |
## Session expiry and audit
When the `ActivationSession` TTL expires, the reconciliation worker handles it:
```
Reconciliation worker (runs every 2 min)
├── reconcile_expired_activations()
│ └── for each expired ActivationSession:
│ ├── _deauthorize_in_zerotier() ───> ZT controller de-authorizes member
│ ├── sets request.active = False
│ └── logs audit event
└── reconcile_all()
└── for each network:
├── sync ZeroTierMembership cache
└── detect/repair drift (portal vs controller state mismatch)
```
Every authorization state change is audited:
| Event | When |
|---|---|
| `zt.approval.requested` | User requests access |
| `zt.approval.granted` | Manager approves/assigns |
| `zt.approval.rejected` | Manager rejects |
| `zt.approval.revoked` | Manager revokes |
| `zt.membership.activated` | User activates (session created) |
| `zt.membership.deactivated` | User deactivates (session ended) |
| `zt.member.authorized` | ZT controller authorize call succeeds |
| `zt.member.deauthorized` | ZT controller de-authorize call succeeds |
| `zt.activation.expired` | Session expired by reconciliation worker |
| `zt.kill_switch.activated` | Admin triggers kill switch |
All audit entries are stored in the `audit_logs` table with `organization_id`, `user_id`, `resource_type`, `resource_id`, `ip_address`, and `extra_data` (JSON) for full traceability.
## Key source files
| File | Purpose |
|---|---|
| `gatehouse_app/models/zerotier/network_access_request.py` | `NetworkAccessRequest` model |
| `gatehouse_app/models/zerotier/activation_session.py` | `ActivationSession` model |
| `gatehouse_app/models/zerotier/zerotier_membership.py` | `ZeroTierMembership` model |
| `gatehouse_app/services/network_access_service.py` | Core business logic |
| `gatehouse_app/services/zerotier_reconciliation_service.py` | Reconciliation worker logic |
| `gatehouse_app/api/v1/zerotier.py` | API endpoints |
| `gatehouse_app/utils/constants.py` | Audit action enums |
| `gatehouse_app/jobs/zerotier_reconciliation_job.py` | Scheduled job entry point |
| `migrations/versions/merge_approval_membership_tables.py` | Schema migration |
+169
View File
@@ -0,0 +1,169 @@
# ZeroTier Kill Switch
## Overview
The kill-switch mechanism provides emergency deactivation of ZeroTier network access at three granularities: a single device membership, all memberships for a user, or all memberships on a network. All kill operations are **reversible** — they set `active=False` and (in most cases) `status=SUSPENDED` but do not delete records, so affected users can re-activate or re-authenticate.
## Three Kill Operations
| Granularity | Endpoint | Admin-only | Behavior |
|---|---|---|---|
| **Device X on network Y** | `POST /orgs/<id>/memberships/<id>/deactivate` | No (owner can self-deactivate) | Sets `active=False`. Status stays APPROVED. |
| **All devices for a user** | `POST /orgs/<id>/kill-switch` | Yes | Sets `active=False`, `status=SUSPENDED` for every active membership in the org (optionally filtered to specific networks). |
| **All devices on a network** | `POST /orgs/<id>/networks/<id>/kill-switch` | Yes | Sets `active=False`, `status=SUSPENDED` for every active membership on the network, across all users. |
## Detailed Endpoint Reference
### 1. Kill a Single Membership (Device + Network)
```
POST /api/v1/organizations/<org_id>/memberships/<membership_id>/deactivate
```
**Auth:** `@login_required`, `@full_access_required`
**Admin override:** Admins can deactivate any membership; non-admins can only deactivate their own.
**Request body:** None
**Response (200):**
```json
{
"success": true,
"data": {
"request": {
"id": "...",
"active": false,
"status": "approved",
...
}
},
"message": "Request deactivated successfully"
}
```
**Behavior:**
- Ends the active `ActivationSession` with reason `manual_revoke`
- De-authorizes the device node in the ZeroTier controller
- Sets `request.active = False` (status **unchanged** — stays `approved`)
- Logs `zt.membership.deactivated` audit event
**Re-activation:** The user can re-activate via `POST /memberships/<id>/activate` or `POST /memberships/activate-all`.
---
### 2. Kill All Devices for a User
```
POST /api/v1/organizations/<org_id>/kill-switch
```
**Auth:** `@login_required`, `@require_admin`, `@full_access_required`
**Request body:**
```json
{
"target_user_id": "uuid-of-user-to-kill",
"scope": "organization",
"network_ids": ["uuid-of-network-1", "uuid-of-network-2"],
"reason": "Security incident — force deactivation"
}
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `target_user_id` | string (UUID) | yes | — | The user to deactivate |
| `scope` | string | no | `"organization"` | `"organization"` (all networks) or `"selected_networks"` |
| `network_ids` | array of UUIDs | no | `null` | Required when scope is `selected_networks` |
| `reason` | string | no | `null` | Max 500 chars |
**Response (200):**
```json
{
"success": true,
"data": {
"affected_count": 3
},
"message": "Kill switch triggered successfully"
}
```
**Behavior:**
- Queries all active, non-deleted `NetworkAccessRequest` rows for the target user
- For each: ends session (reason `kill_switch`), de-authorizes in ZT
- Sets `active = False`, **and** sets `status = SUSPENDED` if currently `APPROVED`
- Logs `zt.kill_switch.activated` audit event with `affected_count` and `scope`
**Re-activation:** The user's memberships are in `SUSPENDED` state. An admin must explicitly re-approve (change status back to `APPROVED`) before the user can re-activate.
---
### 3. Kill All Devices on a Network
```
POST /api/v1/organizations/<org_id>/networks/<network_id>/kill-switch
```
**Auth:** `@login_required`, `@require_admin`, `@full_access_required`
**Request body:**
```json
{
"reason": "Network compromised — emergency deactivation"
}
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `reason` | string | no | `null` | Max 500 chars |
**Response (200):**
```json
{
"success": true,
"data": {
"affected_count": 12
},
"message": "Network kill switch triggered successfully"
}
```
**Behavior:**
- Queries all active, non-deleted `NetworkAccessRequest` rows for the network, **regardless of user**
- For each: ends session (reason `kill_switch`), de-authorizes in ZT
- Sets `active = False`, and `status = SUSPENDED` if currently `APPROVED`
- Logs `zt.network_kill_switch.activated` audit event with `affected_count`
**Re-activation:** Same as user kill switch — each affected membership is `SUSPENDED` and needs admin re-approval.
## Comparison: Deactivation vs. Deletion
| Operation | active | status | deleted_at | DB row | Reversible? |
|---|---|---|---|---|---|
| `POST /memberships/<id>/deactivate` | `false` | unchanged | `null` | preserved | Yes — re-activate |
| `POST /kill-switch` (user) | `false` | `suspended` | `null` | preserved | Yes — admin re-approve |
| `POST /networks/<id>/kill-switch` | `false` | `suspended` | `null` | preserved | Yes — admin re-approve |
| `DELETE /memberships/<id>` (soft) | `false` | unchanged | set | preserved | Partial — depends on join logic |
| `DELETE /admin/memberships/<id>` | `false` | — | — | **hard-deleted** | No |
## Audit Events
| Event | Trigger |
|---|---|
| `zt.membership.deactivated` | Single membership deactivated (endpoint #1) |
| `zt.kill_switch.activated` | User kill switch triggered (endpoint #2) |
| `zt.network_kill_switch.activated` | Network kill switch triggered (endpoint #3) |
All audit entries include `organization_id`, `user_id` (the actor), `resource_type`, `resource_id`, and `metadata` (affected count, scope, network IDs).
## Key Source Files
| File | Purpose |
|---|---|
| `gatehouse_app/api/v1/zerotier.py` | Route handlers for all three endpoints |
| `gatehouse_app/services/network_access_service.py` | `deactivate_request()`, `kill_switch()`, `kill_switch_network()` |
| `gatehouse_app/services/zerotier_api_service.py` | `deauthorize_member()` — ZT controller call |
| `gatehouse_app/utils/constants.py` | `AuditAction` and `KillSwitchScope` enums |
| `gatehouse_app/models/zerotier/network_access_request.py` | `NetworkAccessRequest` model |
| `gatehouse_app/models/zerotier/activation_session.py` | `ActivationSession` model |
| `gatehouse_app/models/zerotier/kill_switch_event.py` | `KillSwitchEvent` model |
| `tests/integration/test_zerotier.py` | Integration tests in `TestZeroTierMembership` |
+139
View File
@@ -0,0 +1,139 @@
# ZeroTier Network Lifecycle
## Overview
This document covers the full lifecycle of ZeroTier networks in Gatehouse — how networks are created, who can see them, how members request access, and how devices are activated and deactivated.
## Organization Membership Roles
Every user belongs to an organization via an `OrganizationMember` record. Roles determine what a user can see and do:
| Role | Can list networks? | Can see invite-only networks? | Can create/update/delete networks? | Can approve access requests? |
|---|---|---|---|---|
| `owner` | Yes | Yes | Yes | Yes |
| `admin` | Yes | Yes | Yes | Yes |
| `member` | Yes | **No** | No | No |
| `guest` | Yes | **No** | No | No |
Role checks happen via the `OrganizationMember.is_admin()` method, which returns `True` for `owner` and `admin`.
## Network Request Modes
Every `PortalNetwork` has a `request_mode` field that controls how users gain access:
| Mode | Value | Behavior |
|---|---|---|
| `open` | `"open"` | Any org member can join directly without approval |
| `approval_required` | `"approval_required"` | User requests access; a manager must approve |
| `invite_only` | `"invite_only"` | Only managers can assign access; invisible to non-admins |
## Network Listing Visibility
`GET /organizations/{org_id}/networks`
The listing endpoint applies two visibility filters:
1. **Soft-delete filter** — networks with a non-null `deleted_at` are always excluded.
2. **Active filter** — by default, only networks where `is_active = True` are returned. Pass `?include_inactive=true` to include disabled networks.
3. **Invite-only filter** — networks with `request_mode = "invite_only"` are hidden from non-admin users (`member` and `guest` roles). Admins and owners see all networks.
### Filtering logic
The filtering happens in `portal_network_service.list_networks()`:
```python
# Non-admin users cannot see invite-only networks
if user_id is not None:
membership = OrganizationMember.query.filter(...).first()
is_admin = membership.is_admin() if membership else False
if not is_admin:
q = q.filter(PortalNetwork.request_mode != NetworkRequestMode.INVITE_ONLY)
```
## Network CRUD
| Action | Endpoint | Required Role |
|---|---|---|
| List networks | `GET /organizations/{id}/networks` | Any org member (visibility restricted as above) |
| Create network | `POST /organizations/{id}/networks` | `admin` or `owner` |
| Update network | `PUT /organizations/{id}/networks/{id}` | `admin` or `owner` |
| Delete network | `DELETE /organizations/{id}/networks/{id}` | `admin` or `owner` |
## Device Registration
Before a user can access a network, they must register a device:
`POST /organizations/{org_id}/devices`
A `Device` record ties a ZeroTier node (10-char `node_id`) to a user within an org.
| Field | Purpose |
|---|---|
| `node_id` | ZeroTier 10-char node identifier |
| `device_nickname` | Human-friendly label |
| `hostname` | Optional hostname for identification |
## Network Access Request Lifecycle
The core model is `NetworkAccessRequest` (table: `network_access_requests`). Each row represents one user + one device + one network. See [zerotier-device-membership.md](zerotier-device-membership.md) for the full schema.
### Flow by request mode
**Open networks** — user calls `join_network_for_device()` directly:
1. Creates `NetworkAccessRequest` with `status=APPROVED`, `active=False`
2. Returns the request
**Approval-required networks** — user calls `request_access()`:
1. Creates `NetworkAccessRequest` with `status=PENDING`
2. Admin calls `approve_request()` → sets `status=APPROVED`
3. User calls `activate_membership()` → sets `active=True`, creates `ActivationSession`
**Invite-only networks** — only an admin can call `assign_access()`:
1. Admin creates `NetworkAccessRequest` with `status=APPROVED`, `grant_type=ASSIGNED`
2. User calls `activate_membership()` → sets `active=True`, creates `ActivationSession`
### The `active` flag
| `status` | `active` | Meaning |
|---|---|---|
| `approved` | `false` | Has permission but not currently connected |
| `approved` | `true` | Has permission and device is authorized on the controller |
| `pending` | `false` | Awaiting approval |
| `rejected` / `revoked` / `suspended` | `false` | Access denied or removed |
## Activation and Deactivation
Activation creates an `ActivationSession` with a configurable TTL (default 8 hours). The session is tied to the `active=True` state.
- `activate_membership()` — sets `active=True`, creates session, authorizes on ZeroTier controller
- `deactivate_membership()` — sets `active=False`, ends session, de-authorizes on controller
- Activation sessions expire automatically via the reconciliation worker, which sets `active=False`
### Kill switch
Admins can trigger a kill switch to deactivate all active memberships on an organization or network:
- `POST /organizations/{id}/kill-switch` — deactivates all memberships in the org
- `POST /organizations/{id}/networks/{id}/kill-switch` — deactivates all memberships on a specific network
## Reconciliation Worker
A scheduled job (runs every 2 minutes) performs:
1. **Expired activation cleanup** — finds expired `ActivationSession` records, de-authorizes in ZeroTier, sets `active=False`
2. **Drift detection** — compares portal state against ZeroTier controller state, repairs mismatches
## Key Source Files
| File | Purpose |
|---|---|
| `gatehouse_app/models/zerotier/portal_network.py` | `PortalNetwork` model (network definition + request_mode) |
| `gatehouse_app/models/zerotier/network_access_request.py` | `NetworkAccessRequest` model (per-device membership) |
| `gatehouse_app/models/zerotier/activation_session.py` | `ActivationSession` model (TTL-based sessions) |
| `gatehouse_app/models/zerotier/device.py` | `Device` model |
| `gatehouse_app/models/organization/organization_member.py` | `OrganizationMember` model (roles) |
| `gatehouse_app/services/portal_network_service.py` | Network CRUD + listing logic |
| `gatehouse_app/services/network_access_service.py` | Access request + activation logic |
| `gatehouse_app/services/zerotier_reconciliation_service.py` | Expired session + drift reconciliation |
| `gatehouse_app/api/v1/zerotier.py` | All ZeroTier API endpoints |
| `gatehouse_app/utils/constants.py` | Enums (`OrganizationRole`, `NetworkRequestMode`, etc.) |
-3
View File
@@ -256,6 +256,3 @@ def initialize_oidc_jwks(app):
app.logger.info(f"[OIDC] Signing key initialized: kid={signing_key.kid}") app.logger.info(f"[OIDC] Signing key initialized: kid={signing_key.kid}")
except Exception as e: except Exception as e:
app.logger.error(f"[OIDC] Failed to initialize JWKS: {e}") app.logger.error(f"[OIDC] Failed to initialize JWKS: {e}")
# Create default app instance for gunicorn/wsgi
app = create_app()
+2
View File
@@ -1,12 +1,14 @@
"""API package.""" """API package."""
from flask import Blueprint from flask import Blueprint
from gatehouse_app.utils.response import api_response from gatehouse_app.utils.response import api_response
from gatehouse_app.extensions import limiter
# Create main API blueprint # Create main API blueprint
api_bp = Blueprint("api", __name__) api_bp = Blueprint("api", __name__)
@api_bp.route("/health", methods=["GET"]) @api_bp.route("/health", methods=["GET"])
@limiter.exempt
def health_check(): def health_check():
"""Health check endpoint.""" """Health check endpoint."""
return api_response( return api_response(
+1 -1
View File
@@ -5,7 +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, sudo, oidc, contact from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, oidc, contact
from gatehouse_app.api.v1 import superadmin from gatehouse_app.api.v1 import superadmin
api_v1_bp.register_blueprint(ssh.ssh_bp) api_v1_bp.register_blueprint(ssh.ssh_bp)
+14
View File
@@ -13,6 +13,7 @@ from gatehouse_app.services.email_templates import build_email_verification_html
from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/auth/register", methods=["POST"]) @api_v1_bp.route("/auth/register", methods=["POST"])
@@ -121,6 +122,19 @@ def login():
user_session = AuthService.create_session(user, duration_seconds=duration, is_compliance_only=is_compliance_only) user_session = AuthService.create_session(user, duration_seconds=duration, is_compliance_only=is_compliance_only)
# Log successful login (after MFA complete, if applicable)
login_org_id = None
if policy_result.compliance_summary and policy_result.compliance_summary.orgs:
login_org_id = policy_result.compliance_summary.orgs[0].organization_id
AuditService.log_action(
action=AuditAction.USER_LOGIN,
user_id=user.id,
organization_id=login_org_id,
description="User logged in (password)",
success=True,
)
response_data = { response_data = {
"user": user.to_dict(), "user": user.to_dict(),
"token": user_session.token, "token": user_session.token,
+23
View File
@@ -1,4 +1,5 @@
"""TOTP authentication endpoints.""" """TOTP authentication endpoints."""
import logging
from flask import request, session, g, current_app from flask import request, session, g, 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
@@ -12,6 +13,7 @@ from gatehouse_app.schemas.auth_schema import (
) )
from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.exceptions.validation_exceptions import ConflictError from gatehouse_app.exceptions.validation_exceptions import ConflictError
@@ -78,6 +80,19 @@ def verify_totp():
is_compliance_only = policy_result.create_compliance_only_session is_compliance_only = policy_result.create_compliance_only_session
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only) user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Log successful login (after MFA complete)
login_org_id = None
if policy_result.compliance_summary and policy_result.compliance_summary.orgs:
login_org_id = policy_result.compliance_summary.orgs[0].organization_id
AuditService.log_action(
action=AuditAction.USER_LOGIN,
user_id=user.id,
organization_id=login_org_id,
description="User logged in (TOTP)",
success=True,
)
session.pop("totp_pending_user_id", None) session.pop("totp_pending_user_id", None)
session.pop("webauthn_pending_user_id", None) session.pop("webauthn_pending_user_id", None)
@@ -112,6 +127,14 @@ def verify_totp():
except ValidationError as e: except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
except InvalidCredentialsError as e: except InvalidCredentialsError as e:
# Log failed TOTP verification
AuditService.log_action(
action=AuditAction.TOTP_VERIFY_FAILED,
user_id=user.id,
description="TOTP verification failed",
success=False,
error_message=e.message,
)
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type) return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
+22
View File
@@ -16,6 +16,7 @@ from gatehouse_app.schemas.webauthn_schema import (
from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.webauthn_service import WebAuthnService from gatehouse_app.services.webauthn_service import WebAuthnService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -128,6 +129,19 @@ def complete_webauthn_login():
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only) user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
session.pop("webauthn_pending_user_id", None) session.pop("webauthn_pending_user_id", None)
# Log successful login (after MFA complete)
login_org_id = None
if policy_result.compliance_summary and policy_result.compliance_summary.orgs:
login_org_id = policy_result.compliance_summary.orgs[0].organization_id
AuditService.log_action(
action=AuditAction.USER_LOGIN,
user_id=user.id,
organization_id=login_org_id,
description="User logged in (WebAuthn)",
success=True,
)
logger.info(f"WebAuthn login completed successfully for user: {user.email}") logger.info(f"WebAuthn login completed successfully for user: {user.email}")
response_data = { response_data = {
@@ -161,6 +175,14 @@ def complete_webauthn_login():
except ValidationError as e: except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
except InvalidCredentialsError as e: except InvalidCredentialsError as e:
# Log failed WebAuthn verification
AuditService.log_action(
action=AuditAction.WEBAUTHN_LOGIN_FAILED,
user_id=user.id,
description="WebAuthn login failed",
success=False,
error_message=e.message,
)
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type) return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
except Exception as e: except Exception as e:
logger.exception(f"WebAuthn login complete unexpected error: {e}") logger.exception(f"WebAuthn login complete unexpected error: {e}")
+56 -4
View File
@@ -10,21 +10,20 @@ from gatehouse_app.models import Department, DepartmentMembership
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.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
class DepartmentCreateSchema(Schema): 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):
@@ -122,11 +121,19 @@ 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()
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( return api_response(
data={"department": dept.to_dict()}, data={"department": dept.to_dict()},
message="Department created successfully", message="Department created successfully",
@@ -255,6 +262,15 @@ def update_department(org_id, dept_id):
db.session.commit() 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( return api_response(
data={"department": dept.to_dict()}, data={"department": dept.to_dict()},
message="Department updated successfully", message="Department updated successfully",
@@ -308,6 +324,15 @@ def delete_department(org_id, dept_id):
dept.deleted_at = db.func.now() dept.deleted_at = db.func.now()
db.session.commit() 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( return api_response(
message="Department deleted successfully", message="Department deleted successfully",
) )
@@ -461,6 +486,15 @@ def add_department_member(org_id, dept_id):
db.session.commit() 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 = membership.to_dict()
member_dict["user"] = user.to_dict() member_dict["user"] = user.to_dict()
@@ -533,6 +567,15 @@ def remove_department_member(org_id, dept_id, user_id):
membership.deleted_at = db.func.now() membership.deleted_at = db.func.now()
db.session.commit() 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( return api_response(
message="Member removed successfully", message="Member removed successfully",
) )
@@ -699,5 +742,14 @@ def set_dept_cert_policy(org_id, dept_id):
db.session.commit() 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") 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.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required 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"]) @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.add(cfg)
db.session.commit() 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( return api_response(
data={"provider": {"id": provider, "client_id": cfg.client_id, "is_enabled": cfg.is_enabled}}, data={"provider": {"id": provider, "client_id": cfg.client_id, "is_enabled": cfg.is_enabled}},
message=f"{provider.capitalize()} OAuth provider configured successfully", 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") return api_response(success=False, message=f"Provider '{provider}' is not configured", status=404, error_type="NOT_FOUND")
cfg.delete() 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") return api_response(message=f"{provider.capitalize()} OAuth provider configuration removed")
+23
View File
@@ -26,6 +26,10 @@ from gatehouse_app.exceptions.auth_exceptions import (
AccountSuspendedError, AccountSuspendedError,
AccountInactiveError, 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
from gatehouse_app.utils.validators import validate_cors_origins
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -813,6 +817,11 @@ def oidc_register():
except Exception: except Exception:
return jsonify({"error": "invalid_request", "error_description": f"Invalid redirect_uri: {uri}"}), 400 return jsonify({"error": "invalid_request", "error_description": f"Invalid redirect_uri: {uri}"}), 400
cors_origins_raw = data.get("allowed_cors_origins")
cors_origins, cors_error = validate_cors_origins(cors_origins_raw)
if cors_error:
return jsonify({"error": "invalid_request", "error_description": cors_error}), 400
client_id = f"oidc_{secrets.token_urlsafe(16)}" client_id = f"oidc_{secrets.token_urlsafe(16)}"
client_secret = f"secret_{secrets.token_urlsafe(24)}" client_secret = f"secret_{secrets.token_urlsafe(24)}"
client_secret_hash = flask_bcrypt.generate_password_hash(client_secret).decode("utf-8") client_secret_hash = flask_bcrypt.generate_password_hash(client_secret).decode("utf-8")
@@ -839,6 +848,7 @@ def oidc_register():
grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]), grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]),
response_types=data.get("response_types", ["code"]), response_types=data.get("response_types", ["code"]),
scopes=data.get("scope", "openid profile email roles").split(), scopes=data.get("scope", "openid profile email roles").split(),
allowed_cors_origins=cors_origins,
is_active=True, is_active=True,
is_confidential=True, is_confidential=True,
require_pkce=True, require_pkce=True,
@@ -849,6 +859,18 @@ def oidc_register():
) )
client.save() 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({ response = jsonify({
"client_id": client_id, "client_id": client_id,
"client_secret": client_secret, "client_secret": client_secret,
@@ -856,6 +878,7 @@ def oidc_register():
"client_secret_expires_at": 0, "client_secret_expires_at": 0,
"client_name": client_name, "client_name": client_name,
"redirect_uris": redirect_uris, "redirect_uris": redirect_uris,
"allowed_cors_origins": client.allowed_cors_origins,
"token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_basic"), "token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_basic"),
"grant_types": client.grant_types, "grant_types": client.grant_types,
"response_types": client.response_types, "response_types": client.response_types,
@@ -1,4 +1,4 @@
"""Organization routes package.""" """Organization routes package."""
from gatehouse_app.api.v1.organizations import core, members, invites, clients, cas, audit, roles, api_keys from gatehouse_app.api.v1.organizations import core, members, invites, clients, cas, audit, roles
__all__ = ["core", "members", "invites", "clients", "cas", "audit", "roles", "api_keys"] __all__ = ["core", "members", "invites", "clients", "cas", "audit", "roles"]
@@ -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.models.organization import OrganizationApiKey
from gatehouse_app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
class ApiKeyCreateSchema(Schema): class ApiKeyCreateSchema(Schema):
@@ -131,6 +133,15 @@ def create_api_key(org_id):
description=data.get("description"), 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) # Return the key data with the plain text key (only on creation)
key_dict = api_key.to_dict() key_dict = api_key.to_dict()
key_dict["key"] = plain_key # Include plain text only on creation key_dict["key"] = plain_key # Include plain text only on creation
@@ -222,6 +233,15 @@ def update_api_key(org_id, key_id):
api_key.save() 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( return api_response(
data={"api_key": api_key.to_dict()}, data={"api_key": api_key.to_dict()},
message="API key updated successfully", message="API key updated successfully",
@@ -294,6 +314,15 @@ def delete_api_key(org_id, key_id):
# Soft delete the API key # Soft delete the API key
api_key.delete(soft=True) 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( return api_response(
message="API key deleted successfully", message="API key deleted successfully",
) )
@@ -43,10 +43,13 @@ def get_organization_audit_logs(org_id):
page = int(request.args.get("page", 1)) page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200) per_page = min(int(request.args.get("per_page", 50)), 200)
action_filter = request.args.get("action") action_filter = request.args.get("action")
user_id_filter = request.args.get("user_id")
query = AuditLog.query.filter_by(organization_id=org_id) query = AuditLog.query.filter_by(organization_id=org_id)
if action_filter: if action_filter:
query = query.filter_by(action=action_filter) query = query.filter_by(action=action_filter)
if user_id_filter:
query = query.filter_by(user_id=user_id_filter)
query = query.order_by(AuditLog.created_at.desc()) query = query.order_by(AuditLog.created_at.desc())
total = query.count() total = query.count()
+26 -9
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.utils.decorators import login_required, require_admin
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.api.v1.organizations._helpers import _get_system_ca_dict 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"]) @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"] ca.max_cert_validity_hours = data["max_cert_validity_hours"]
db.session.commit() 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") return api_response(data={"ca": ca.to_dict()}, message="CA updated successfully")
except ValidationError as e: except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) 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") 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 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) return api_response(data={"ca": ca.to_dict()}, message="CA created successfully", status=201)
except MaValidationError as e: except MaValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
@@ -182,13 +203,12 @@ def delete_org_ca(org_id, ca_id):
ca.is_active = False ca.is_active = False
ca.delete(soft=True) ca.delete(soft=True)
AuditLog.log( AuditService.log_action(
action=AuditAction.CA_DELETED, action=AuditAction.CA_DELETED,
user_id=g.current_user.id, user_id=g.current_user.id,
organization_id=org_id,
resource_type="CA", resource_type="CA",
resource_id=ca_id, resource_id=ca_id,
organization_id=org_id,
ip_address=request.remote_addr,
description=f"CA '{ca_name}' ({ca_type}) deleted", description=f"CA '{ca_name}' ({ca_type}) deleted",
) )
return api_response(data={"ca_id": ca_id}, message="CA deleted successfully") return api_response(data={"ca_id": ca_id}, message="CA deleted successfully")
@@ -206,8 +226,6 @@ def rotate_org_ca(org_id, ca_id):
from gatehouse_app.models.organization.organization import Organization from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.utils.crypto import compute_ssh_fingerprint from gatehouse_app.utils.crypto import compute_ssh_fingerprint
from gatehouse_app.utils.ca_key_encryption import encrypt_ca_key from gatehouse_app.utils.ca_key_encryption import encrypt_ca_key
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.models import AuditLog
from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey
org = Organization.query.filter_by(id=org_id, deleted_at=None).first() org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
@@ -244,14 +262,13 @@ def rotate_org_ca(org_id, ca_id):
ca.key_type = KeyType(new_key_type) ca.key_type = KeyType(new_key_type)
db.session.commit() db.session.commit()
AuditLog.log( AuditService.log_action(
action=AuditAction.CA_KEY_ROTATED, action=AuditAction.CA_KEY_ROTATED,
user_id=g.current_user.id, user_id=g.current_user.id,
organization_id=org_id,
resource_type="CA", resource_type="CA",
resource_id=ca_id, resource_id=ca_id,
organization_id=org_id, description=f"CA '{ca.name}' key rotated. Old fingerprint: {old_fingerprint}, New fingerprint: {new_fingerprint}. Reason: {reason}",
ip_address=request.remote_addr,
description=(f"CA '{ca.name}' key rotated. Old fingerprint: {old_fingerprint}, New fingerprint: {new_fingerprint}. Reason: {reason}"),
) )
return api_response(data={"ca": ca.to_dict(), "old_fingerprint": old_fingerprint}, message="CA key rotated successfully. Update TrustedUserCAKeys / known_hosts on your servers.") return api_response(data={"ca": ca.to_dict(), "old_fingerprint": old_fingerprint}, message="CA key rotated successfully. Update TrustedUserCAKeys / known_hosts on your servers.")
@@ -5,6 +5,9 @@ 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
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.extensions import db, bcrypt from gatehouse_app.extensions import db, bcrypt
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.validators import validate_cors_origins
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"]) @api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
@@ -28,6 +31,7 @@ def list_org_clients(org_id):
"redirect_uris": c.redirect_uris, "redirect_uris": c.redirect_uris,
"scopes": c.scopes, "scopes": c.scopes,
"grant_types": c.grant_types, "grant_types": c.grant_types,
"allowed_cors_origins": c.allowed_cors_origins,
"is_active": c.is_active, "is_active": c.is_active,
"created_at": c.created_at.isoformat() + "Z", "created_at": c.created_at.isoformat() + "Z",
} }
@@ -60,6 +64,11 @@ def create_org_client(org_id):
if not redirect_uris: if not redirect_uris:
return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR") return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR")
cors_origins_raw = data.get("allowed_cors_origins")
cors_origins, cors_error = validate_cors_origins(cors_origins_raw)
if cors_error:
return api_response(success=False, message=cors_error, status=400, error_type="VALIDATION_ERROR")
client_id = _secrets.token_hex(16) client_id = _secrets.token_hex(16)
client_secret = _secrets.token_urlsafe(32) client_secret = _secrets.token_urlsafe(32)
@@ -72,12 +81,22 @@ def create_org_client(org_id):
grant_types=["authorization_code", "refresh_token"], grant_types=["authorization_code", "refresh_token"],
response_types=["code"], response_types=["code"],
scopes=["openid", "profile", "email"], scopes=["openid", "profile", "email"],
allowed_cors_origins=cors_origins,
is_active=True, is_active=True,
is_confidential=True, is_confidential=True,
) )
db.session.add(client) db.session.add(client)
db.session.commit() 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( return api_response(
data={ data={
"client": { "client": {
@@ -87,6 +106,7 @@ def create_org_client(org_id):
"client_secret": client_secret, "client_secret": client_secret,
"redirect_uris": client.redirect_uris, "redirect_uris": client.redirect_uris,
"scopes": client.scopes, "scopes": client.scopes,
"allowed_cors_origins": client.allowed_cors_origins,
"created_at": client.created_at.isoformat() + "Z", "created_at": client.created_at.isoformat() + "Z",
} }
}, },
@@ -123,8 +143,23 @@ def update_org_client(org_id, client_id):
return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR") return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR")
client.redirect_uris = uris client.redirect_uris = uris
if "allowed_cors_origins" in data:
cors_origins, cors_error = validate_cors_origins(data["allowed_cors_origins"])
if cors_error:
return api_response(success=False, message=cors_error, status=400, error_type="VALIDATION_ERROR")
client.allowed_cors_origins = cors_origins
db.session.commit() 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( return api_response(
data={ data={
"client": { "client": {
@@ -134,6 +169,7 @@ def update_org_client(org_id, client_id):
"redirect_uris": client.redirect_uris, "redirect_uris": client.redirect_uris,
"scopes": client.scopes, "scopes": client.scopes,
"grant_types": client.grant_types, "grant_types": client.grant_types,
"allowed_cors_origins": client.allowed_cors_origins,
"is_active": client.is_active, "is_active": client.is_active,
"created_at": client.created_at.isoformat() + "Z", "created_at": client.created_at.isoformat() + "Z",
} }
@@ -154,4 +190,14 @@ def delete_org_client(org_id, client_id):
client.is_active = False client.is_active = False
db.session.commit() 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") 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.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.schemas.organization_schema import OrganizationCreateSchema, OrganizationUpdateSchema from gatehouse_app.schemas.organization_schema import OrganizationCreateSchema, OrganizationUpdateSchema
from gatehouse_app.services.organization_service import OrganizationService 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"]) @api_v1_bp.route("/organizations", methods=["POST"])
@@ -32,6 +34,14 @@ def create_organization():
description=data.get("description"), description=data.get("description"),
logo_url=data.get("logo_url"), 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) return api_response(data={"organization": org.to_dict()}, message="Organization created successfully", status=201)
except ValidationError as e: except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) 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) data = schema.load(request.json)
org = OrganizationService.get_organization_by_id(org_id) org = OrganizationService.get_organization_by_id(org_id)
org = OrganizationService.update_organization(org=org, user_id=g.current_user.id, **data) 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") return api_response(data={"organization": org.to_dict()}, message="Organization updated successfully")
except ValidationError as e: except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) 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) 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") 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) return api_response(success=False, message="Invite not found", status=404)
invite.delete(soft=True) 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") return api_response(data={}, message="Invite cancelled")
+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.schemas.organization_schema import InviteMemberSchema, UpdateMemberRoleSchema
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 AuditAction, OrganizationRole
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/organizations/<org_id>/members", methods=["GET"]) @api_v1_bp.route("/organizations/<org_id>/members", methods=["GET"])
@@ -43,6 +44,14 @@ def add_organization_member(org_id):
role = OrganizationRole(data["role"]) role = OrganizationRole(data["role"])
member = OrganizationService.add_member(org=org, user_id=user.id, role=role, inviter_id=g.current_user.id) 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 = member.to_dict()
member_dict["user"] = user.to_dict() member_dict["user"] = user.to_dict()
return api_response(data={"member": member_dict}, message="Member added successfully", status=201) 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) OrganizationService.remove_member(org=org, user_id=user_id, remover_id=g.current_user.id)
except ValueError as e: except ValueError as e:
return api_response(success=False, message=str(e), status=403, error_type="OWNER_PROTECTION") 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") 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) org = OrganizationService.get_organization_by_id(org_id)
new_role = OrganizationRole(data["role"]) 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) 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 = member.to_dict()
member_dict["user"] = member.user.to_dict() member_dict["user"] = member.user.to_dict()
return api_response(data={"member": member_dict}, message="Member role updated successfully") 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, 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") 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.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response 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.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.extensions import db
from gatehouse_app.services.audit_service import AuditService
@api_v1_bp.route("/organizations/<org_id>/roles", methods=["GET"]) @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 membership.role = new_role
db.session.commit() 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}") 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) org = OrganizationService.get_organization_by_id(org_id)
OrganizationService.remove_member(org=org, user_id=user_id, remover_id=g.current_user.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") return api_response(data={"user_id": user_id}, message="Member removed from organization")
+1 -12
View File
@@ -6,8 +6,7 @@ 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.decorators import login_required, require_admin, full_access_required
from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.utils.constants import MfaPolicyMode, MfaRequirementOverride, MfaComplianceStatus
from gatehouse_app.utils.constants import MfaPolicyMode, MfaRequirementOverride, MfaComplianceStatus, AuditAction
class UpdateOrgPolicySchema(Schema): class UpdateOrgPolicySchema(Schema):
@@ -291,16 +290,6 @@ def update_user_security_policy(org_id, user_id):
updated_by_user_id=g.current_user.id, updated_by_user_id=g.current_user.id,
) )
# Log the override change with details
AuditService.log_action(
action=AuditAction.USER_SECURITY_POLICY_OVERRIDE_UPDATE,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=user_id,
description=f"User security policy override changed to {data['mfa_override_mode']} for user {user_id}",
)
return api_response( return api_response(
data={ data={
"user_security_policy": { "user_security_policy": {
+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.services.user_service import UserService
from gatehouse_app.exceptions import OrganizationNotFoundError from gatehouse_app.exceptions import OrganizationNotFoundError
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
class PrincipalCreateSchema(Schema): class PrincipalCreateSchema(Schema):
@@ -127,6 +129,15 @@ def create_principal(org_id):
db.session.add(principal) db.session.add(principal)
db.session.commit() 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( return api_response(
data={"principal": principal.to_dict()}, data={"principal": principal.to_dict()},
message="Principal created successfully", message="Principal created successfully",
@@ -255,6 +266,15 @@ def update_principal(org_id, principal_id):
db.session.commit() 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( return api_response(
data={"principal": principal.to_dict()}, data={"principal": principal.to_dict()},
message="Principal updated successfully", message="Principal updated successfully",
@@ -308,6 +328,15 @@ def delete_principal(org_id, principal_id):
principal.deleted_at = db.func.now() principal.deleted_at = db.func.now()
db.session.commit() 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( return api_response(
message="Principal deleted successfully", message="Principal deleted successfully",
) )
@@ -476,6 +505,15 @@ def add_principal_member(org_id, principal_id):
db.session.commit() 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 = membership.to_dict()
member_dict["user"] = user.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() membership.deleted_at = db.func.now()
db.session.commit() 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( return api_response(
message="Member removed successfully", message="Member removed successfully",
) )
@@ -697,6 +744,15 @@ def link_principal_to_department(org_id, principal_id, dept_id):
error_type="SERVER_ERROR", 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( return api_response(
data={ data={
"principal": principal.to_dict(), "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() link.deleted_at = db.func.now()
db.session.commit() 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( return api_response(
message="Principal unlinked from department successfully", message="Principal unlinked from department successfully",
) )
+21 -3
View File
@@ -2,7 +2,7 @@
from flask import request, g from flask import request, g
from gatehouse_app.api.v1.ssh._helpers import ssh_bp from gatehouse_app.api.v1.ssh._helpers import ssh_bp
from gatehouse_app.utils.constants import AuditAction, OrganizationRole from gatehouse_app.utils.constants import AuditAction, OrganizationRole
from gatehouse_app.models import AuditLog from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.response import api_response from gatehouse_app.utils.response import api_response
@@ -78,7 +78,14 @@ def add_ca_permission(ca_id):
db.session.add(perm) db.session.add(perm)
db.session.commit() db.session.commit()
AuditLog.log(action=AuditAction.CA_UPDATED, user_id=user.id, resource_type="CAPermission", resource_id=perm.id, ip_address=request.remote_addr, description=f"Granted '{permission}' on CA '{ca.name}' to user {target_user.email}") AuditService.log_action(
action=AuditAction.CA_UPDATED,
user_id=user.id,
organization_id=ca.organization_id,
resource_type="CAPermission",
resource_id=perm.id,
description=f"Granted '{permission}' on CA '{ca.name}' to user {target_user.email}",
)
d = perm.to_dict() d = perm.to_dict()
d["user_email"] = target_user.email d["user_email"] = target_user.email
@@ -102,10 +109,21 @@ def remove_ca_permission(ca_id, target_user_id):
if not membership or membership.role not in (OrganizationRole.ADMIN, OrganizationRole.OWNER): if not membership or membership.role not in (OrganizationRole.ADMIN, OrganizationRole.OWNER):
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN") return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
target_user = User.query.filter_by(id=target_user_id, deleted_at=None).first()
if not target_user:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
perm = CAPermission.query.filter_by(ca_id=ca_id, user_id=target_user_id, deleted_at=None).first() perm = CAPermission.query.filter_by(ca_id=ca_id, user_id=target_user_id, deleted_at=None).first()
if not perm: if not perm:
return api_response(success=False, message="Permission not found", status=404, error_type="NOT_FOUND") return api_response(success=False, message="Permission not found", status=404, error_type="NOT_FOUND")
perm.delete(soft=True) perm.delete(soft=True)
AuditLog.log(action=AuditAction.CA_UPDATED, user_id=user.id, resource_type="CAPermission", resource_id=perm.id, ip_address=request.remote_addr, description=f"Revoked permission on CA '{ca.name}' from user {target_user_id}") AuditService.log_action(
action=AuditAction.CA_UPDATED,
user_id=user.id,
organization_id=ca.organization_id,
resource_type="CAPermission",
resource_id=perm.id,
description=f"Revoked permission on CA '{ca.name}' from user {target_user.email}",
)
return api_response(data={}, message="Permission revoked") return api_response(data={}, message="Permission revoked")
+52 -19
View File
@@ -8,7 +8,7 @@ from gatehouse_app.api.v1.ssh._helpers import (
from gatehouse_app.services.ssh_ca_signing_service import SSHCertificateSigningRequest from gatehouse_app.services.ssh_ca_signing_service import SSHCertificateSigningRequest
from gatehouse_app.exceptions import SSHKeyNotFoundError, SSHCertificateError from gatehouse_app.exceptions import SSHKeyNotFoundError, SSHCertificateError
from gatehouse_app.utils.constants import AuditAction, OrganizationRole from gatehouse_app.utils.constants import AuditAction, OrganizationRole
from gatehouse_app.models import AuditLog from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.models.ssh_ca.certificate_audit_log import CertificateAuditLog from gatehouse_app.models.ssh_ca.certificate_audit_log import CertificateAuditLog
from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.response import api_response from gatehouse_app.utils.response import api_response
@@ -68,10 +68,11 @@ def sign_certificate():
expiry_hours = data.get('expiry_hours') expiry_hours = data.get('expiry_hours')
requested_org_id = data.get('organization_id') requested_org_id = data.get('organization_id')
AuditLog.log( AuditService.log_action(
action=AuditAction.SSH_CERT_REQUESTED, action=AuditAction.SSH_CERT_REQUESTED,
user_id=user_id, resource_type='SSHCertificate', ip_address=request.remote_addr, user_id=user_id,
description=(f'{user.email} requested a certificate' + (f' for principals: {", ".join(requested_principals)}' if requested_principals else '')), resource_type="SSHCertificate",
description=f"{user.email} requested a certificate" + (f" for principals: {', '.join(requested_principals)}" if requested_principals else ""),
) )
# Validate organization_id if provided # Validate organization_id if provided
@@ -209,10 +210,24 @@ def sign_certificate():
ca_private_key_pem = decrypt_ca_key(db_ca.private_key) ca_private_key_pem = decrypt_ca_key(db_ca.private_key)
response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key_pem, ca_obj=db_ca) response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key_pem, ca_obj=db_ca)
except SSHCertificateError as e: 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)) AuditService.log_action(
action=AuditAction.SSH_CERT_FAILED,
user_id=user_id,
resource_type="SSHCertificate",
description=f"Certificate signing failed",
success=False,
error_message=str(e),
)
return api_response(success=False, message=str(e), status=400, error_type="SIGNING_FAILED") return api_response(success=False, message=str(e), status=400, error_type="SIGNING_FAILED")
except Exception as e: 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)) AuditService.log_action(
action=AuditAction.SSH_CERT_FAILED,
user_id=user_id,
resource_type="SSHCertificate",
description=f"Certificate signing failed",
success=False,
error_message=str(e),
)
return api_response(success=False, message="Certificate signing failed", status=500, error_type="SERVER_ERROR") return api_response(success=False, message="Certificate signing failed", status=500, error_type="SERVER_ERROR")
cert_record = _persist_certificate( cert_record = _persist_certificate(
@@ -221,12 +236,14 @@ def sign_certificate():
cert_type_str=cert_type, cert_identity=cert_identity, cert_type_str=cert_type, cert_identity=cert_identity,
) )
AuditLog.log( AuditService.log_action(
action=AuditAction.SSH_CERT_ISSUED, user_id=user_id, action=AuditAction.SSH_CERT_ISSUED,
resource_type='SSHCertificate', resource_id=cert_record.id if cert_record else key_id, user_id=user_id,
ip_address=request.remote_addr, organization_id=str(target_org.id),
description=f'Certificate serial={response.serial} issued for {user.email}; principals: {", ".join(principals)}', resource_type="SSHCertificate",
extra_data={'serial': response.serial, 'key_id': cert_identity, 'principals': principals, 'ca_id': str(db_ca.id), 'ssh_key_id': str(key_id), 'organization_id': str(target_org.id), 'organization_name': target_org.name}, resource_id=cert_record.id if cert_record else key_id,
metadata={"serial": response.serial, "key_id": cert_identity, "principals": principals, "ca_id": str(db_ca.id), "ssh_key_id": str(key_id)},
description=f"Certificate serial={response.serial} issued for {user.email}; principals: {', '.join(principals)}",
) )
if cert_record: if cert_record:
@@ -340,7 +357,15 @@ def sign_host_certificate():
ca_private_key_pem = decrypt_ca_key(host_ca.private_key) ca_private_key_pem = decrypt_ca_key(host_ca.private_key)
response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key_pem, ca_obj=host_ca) response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key_pem, ca_obj=host_ca)
except Exception as exc: except Exception as exc:
AuditLog.log(action=AuditAction.SSH_CERT_FAILED, user_id=user_id, resource_type="SSHCertificate", ip_address=request.remote_addr, success=False, error_message=str(exc)) AuditService.log_action(
action=AuditAction.SSH_CERT_FAILED,
user_id=user_id,
organization_id=host_ca.organization_id,
resource_type="SSHCertificate",
description=f"Host certificate signing failed",
success=False,
error_message=str(exc),
)
return api_response(success=False, message=f"Host certificate signing failed: {exc}", status=500, error_type="SIGNING_FAILED") return api_response(success=False, message=f"Host certificate signing failed: {exc}", status=500, error_type="SIGNING_FAILED")
cert_record = _persist_certificate( cert_record = _persist_certificate(
@@ -349,12 +374,14 @@ def sign_host_certificate():
cert_type_str="host", cert_identity=cert_identity, cert_type_str="host", cert_identity=cert_identity,
) )
AuditLog.log( AuditService.log_action(
action=AuditAction.SSH_CERT_ISSUED, user_id=user_id, action=AuditAction.SSH_CERT_ISSUED,
resource_type="SSHCertificate", resource_id=cert_record.id if cert_record else None, user_id=user_id,
ip_address=request.remote_addr, organization_id=host_ca.organization_id,
resource_type="SSHCertificate",
resource_id=cert_record.id if cert_record else None,
metadata={"serial": response.serial, "principals": principals, "ca_id": str(host_ca.id), "cert_type": "host"},
description=f"Host certificate serial={response.serial} issued for {primary_principal} by {user.email}", description=f"Host certificate serial={response.serial} issued for {primary_principal} by {user.email}",
extra_data={"serial": response.serial, "principals": principals, "ca_id": str(host_ca.id), "cert_type": "host"},
) )
result = { result = {
@@ -415,7 +442,13 @@ def revoke_certificate(cert_id):
return api_response(success=False, message='Certificate is already revoked', status=409, error_type='ALREADY_REVOKED') return api_response(success=False, message='Certificate is already revoked', status=409, error_type='ALREADY_REVOKED')
cert.revoke(reason=reason) cert.revoke(reason=reason)
AuditLog.log(action=AuditAction.SSH_CERT_REVOKED, user_id=user_id, resource_type='SSHCertificate', resource_id=cert_id, ip_address=request.remote_addr, description=f'Revoked: {reason}') AuditService.log_action(
action=AuditAction.SSH_CERT_REVOKED,
user_id=user_id,
resource_type="SSHCertificate",
resource_id=cert_id,
description=f"Certificate revoked: {reason}",
)
# Get organization from certificate's CA for audit logging # Get organization from certificate's CA for audit logging
from gatehouse_app.models.ssh_ca.ca import CA from gatehouse_app.models.ssh_ca.ca import CA
+32 -5
View File
@@ -4,7 +4,7 @@ from flask import request, g
from gatehouse_app.api.v1.ssh._helpers import ssh_bp, ssh_key_service from gatehouse_app.api.v1.ssh._helpers import ssh_bp, ssh_key_service
from gatehouse_app.exceptions import SSHKeyError, SSHKeyNotFoundError, ValidationError, SSHKeyAlreadyExistsError from gatehouse_app.exceptions import SSHKeyError, SSHKeyNotFoundError, ValidationError, SSHKeyAlreadyExistsError
from gatehouse_app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.models import AuditLog from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.response import api_response from gatehouse_app.utils.response import api_response
@@ -34,7 +34,13 @@ def add_ssh_key():
try: try:
ssh_key, is_new = ssh_key_service.add_ssh_key(user_id=user_id, public_key=public_key, description=description) ssh_key, is_new = ssh_key_service.add_ssh_key(user_id=user_id, public_key=public_key, description=description)
if is_new: if is_new:
AuditLog.log(action=AuditAction.SSH_KEY_ADDED, user_id=user_id, resource_type='SSHKey', resource_id=ssh_key.id, ip_address=request.remote_addr) AuditService.log_action(
action=AuditAction.SSH_KEY_ADDED,
user_id=user_id,
resource_type="SSHKey",
resource_id=ssh_key.id,
description=f"SSH key added",
)
return api_response(success=True, message='SSH key added', data=ssh_key.to_dict(), status=201) return api_response(success=True, message='SSH key added', data=ssh_key.to_dict(), status=201)
else: else:
return api_response(success=True, message='SSH key already exists', data=ssh_key.to_dict(), status=200) return api_response(success=True, message='SSH key already exists', data=ssh_key.to_dict(), status=200)
@@ -68,7 +74,13 @@ def delete_ssh_key(key_id):
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN') 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)
AuditLog.log(action=AuditAction.SSH_KEY_DELETED, user_id=user_id, resource_type='SSHKey', resource_id=key_id, ip_address=request.remote_addr) AuditService.log_action(
action=AuditAction.SSH_KEY_DELETED,
user_id=user_id,
resource_type="SSHKey",
resource_id=key_id,
description=f"SSH key deleted",
)
return api_response(success=True, message='SSH key deleted', data={'status': 'deleted'}, status=200) return api_response(success=True, message='SSH key deleted', data={'status': 'deleted'}, status=200)
except SSHKeyNotFoundError: except SSHKeyNotFoundError:
return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND') return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
@@ -96,10 +108,25 @@ def verify_ssh_key(key_id):
return api_response(success=False, message='signature is required', status=400, error_type='BAD_REQUEST') 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)
AuditLog.log(action=AuditAction.SSH_KEY_VERIFIED, user_id=user_id, resource_type='SSHKey', resource_id=key_id, ip_address=request.remote_addr, success=verified) AuditService.log_action(
action=AuditAction.SSH_KEY_VERIFIED,
user_id=user_id,
resource_type="SSHKey",
resource_id=key_id,
description=f"SSH key verified",
success=verified,
)
return api_response(success=True, message='Verification complete', data={'verified': verified}, status=200) return api_response(success=True, message='Verification complete', data={'verified': verified}, status=200)
except Exception as e: except Exception as e:
AuditLog.log(action=AuditAction.SSH_KEY_VALIDATION_FAILED, user_id=user_id, resource_type='SSHKey', resource_id=key_id, ip_address=request.remote_addr, success=False, error_message=str(e)) AuditService.log_action(
action=AuditAction.SSH_KEY_VALIDATION_FAILED,
user_id=user_id,
resource_type="SSHKey",
resource_id=key_id,
description=f"SSH key validation failed",
success=False,
error_message=str(e),
)
return api_response(success=False, message=str(e), status=400, error_type='VERIFICATION_FAILED') return api_response(success=False, message=str(e), status=400, error_type='VERIFICATION_FAILED')
else: else:
challenge = ssh_key_service.generate_verification_challenge(key_id) challenge = ssh_key_service.generate_verification_challenge(key_id)
-137
View File
@@ -1,137 +0,0 @@
"""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",
)
+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.services.superadmin_auth_service import SuperadminAuthService
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.utils.constants import AuditAction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -105,6 +106,7 @@ def login():
@superadmin_bp.route("/auth/logout", methods=["POST"]) @superadmin_bp.route("/auth/logout", methods=["POST"])
@superadmin_required @superadmin_required
@superadmin_audit_log(action=AuditAction.USER_LOGOUT, resource_type="session")
def logout(): def logout():
"""Superadmin logout endpoint. """Superadmin logout endpoint.
+232
View File
@@ -514,3 +514,235 @@ def remove_user_from_org(user_id, org_id):
status=500, status=500,
error_type="INTERNAL_ERROR", error_type="INTERNAL_ERROR",
) )
# ============ User Audit Log Endpoints ============
@superadmin_bp.route("/users/<user_id>/audit-logs", methods=["GET"])
@superadmin_required
def get_user_audit_logs(user_id):
"""Get audit logs for a specific user (superadmin only).
Query params:
page: Page number (default 1)
per_page: Items per page (default 50, max 200)
action: Filter by action type
success: Filter by success (true/false)
start_date: Filter by start date (ISO 8601)
end_date: Filter by end date (ISO 8601)
"""
try:
from gatehouse_app.models.auth.audit_log import AuditLog
from gatehouse_app.models.user.user import User
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
page = max(1, int(request.args.get("page", 1)))
per_page = min(200, max(1, int(request.args.get("per_page", 50))))
query = AuditLog.query.filter_by(user_id=user_id)
# Filters
action_filter = request.args.get("action")
if action_filter:
query = query.filter(AuditLog.action == action_filter)
success_filter = request.args.get("success")
if success_filter is not None:
query = query.filter(AuditLog.success == (success_filter.lower() == "true"))
start_date = request.args.get("start_date")
if start_date:
from datetime import datetime, timezone
try:
start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at >= start_dt)
except ValueError:
return api_response(
success=False,
message="Invalid start_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
end_date = request.args.get("end_date")
if end_date:
from datetime import datetime, timezone
try:
end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at <= end_dt)
except ValueError:
return api_response(
success=False,
message="Invalid end_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
query = query.order_by(AuditLog.created_at.desc())
total = query.count()
logs = query.offset((page - 1) * per_page).limit(per_page).all()
def log_to_dict(log):
action = log.action
return {
"id": log.id,
"action": action.value if hasattr(action, "value") else action,
"user_id": log.user_id,
"user": (
{"id": log.user.id, "email": log.user.email, "full_name": log.user.full_name}
if log.user else None
),
"organization_id": log.organization_id,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"description": log.description,
"success": log.success,
"error_message": log.error_message,
"metadata": log.extra_data,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
return api_response(
data={
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
},
"audit_logs": [log_to_dict(log) for log in logs],
"count": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
},
message="User audit logs retrieved successfully",
)
except Exception as e:
logger.error(f"[SuperadminUsers] Get user audit logs error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/audit-logs/export", methods=["GET"])
@superadmin_required
def export_user_audit_logs(user_id):
"""Export audit logs for a specific user as CSV (superadmin only).
Query params:
action: Filter by action type
success: Filter by success (true/false)
start_date: Filter by start date (ISO 8601)
end_date: Filter by end date (ISO 8601)
"""
try:
from gatehouse_app.models.auth.audit_log import AuditLog
from gatehouse_app.models.user.user import User
import csv
from flask import make_response
from io import StringIO
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
query = AuditLog.query.filter_by(user_id=user_id)
# Apply same filters as get_user_audit_logs
action_filter = request.args.get("action")
if action_filter:
query = query.filter(AuditLog.action == action_filter)
success_filter = request.args.get("success")
if success_filter is not None:
query = query.filter(AuditLog.success == (success_filter.lower() == "true"))
start_date = request.args.get("start_date")
if start_date:
from datetime import datetime, timezone
try:
start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at >= start_dt)
except ValueError:
return api_response(
success=False,
message="Invalid start_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
end_date = request.args.get("end_date")
if end_date:
from datetime import datetime, timezone
try:
end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at <= end_dt)
except ValueError:
return api_response(
success=False,
message="Invalid end_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
query = query.order_by(AuditLog.created_at.desc())
logs = query.all()
# Generate CSV
output = StringIO()
writer = csv.writer(output)
writer.writerow([
"id", "timestamp", "action", "user_email", "organization_id",
"resource_type", "resource_id", "ip_address", "success",
"description", "error_message"
])
for log in logs:
action = log.action
writer.writerow([
log.id,
log.created_at.isoformat() if log.created_at else "",
action.value if hasattr(action, "value") else action,
log.user.email if log.user else "",
log.organization_id or "",
log.resource_type or "",
log.resource_id or "",
log.ip_address or "",
log.success,
log.description or "",
log.error_message or "",
])
response = make_response(output.getvalue())
response.headers["Content-Type"] = "text/csv"
response.headers["Content-Disposition"] = f"attachment; filename=user_{user_id}_audit_logs.csv"
return response
except Exception as e:
logger.error(f"[SuperadminUsers] Export user audit logs error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
+125 -13
View File
@@ -306,10 +306,10 @@ def admin_delete_user(user_id):
from gatehouse_app.models.user.user import User as _User from gatehouse_app.models.user.user import User as _User
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
from gatehouse_app.models.auth.authentication_method import OAuthState
from gatehouse_app.extensions import db as _db from gatehouse_app.extensions import db as _db
from gatehouse_app.utils.constants import AuditAction, OrganizationRole from gatehouse_app.utils.constants import AuditAction, OrganizationRole
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.user_service import UserService
caller = g.current_user caller = g.current_user
data = request.get_json() or {} data = request.get_json() or {}
@@ -372,20 +372,10 @@ def admin_delete_user(user_id):
target_email = target.email target_email = target.email
target_id_str = str(target.id) target_id_str = str(target.id)
now = datetime.now(timezone.utc)
try: try:
# Soft delete the user — set deleted_at timestamp. UserService.delete_user(target, soft=True)
target.deleted_at = now
# Soft delete associated OAuthState records.
OAuthState.query.filter_by(user_id=target_id_str).filter(OAuthState.deleted_at == None).update(
{"deleted_at": now}, synchronize_session=False
)
_db.session.flush()
except Exception as exc: except Exception as exc:
_db.session.rollback()
_logger.error(f"Soft delete failed for {target_id_str}: {exc}") _logger.error(f"Soft delete failed for {target_id_str}: {exc}")
return api_response(success=False, message="Failed to delete user account. Please try again.", status=500, error_type="SERVER_ERROR") return api_response(success=False, message="Failed to delete user account. Please try again.", status=500, error_type="SERVER_ERROR")
@@ -443,7 +433,7 @@ def admin_restore_user(user_id):
_db.session.commit() _db.session.commit()
AuditService.log_action( AuditService.log_action(
action=AuditAction.USER_UNSUSPEND, # closest existing action action=AuditAction.USER_RESTORE,
user_id=caller.id, user_id=caller.id,
organization_id=_get_admin_access(caller, target).organization_id, organization_id=_get_admin_access(caller, target).organization_id,
resource_type="user", resource_id=str(target.id), resource_type="user", resource_id=str(target.id),
@@ -710,6 +700,128 @@ def admin_set_user_password(user_id):
return api_response(data={"user": {"id": str(target.id), "email": target.email}}, message=f"Password updated for {target.email}") return api_response(data={"user": {"id": str(target.id), "email": target.email}}, message=f"Password updated for {target.email}")
@api_v1_bp.route("/admin/users/<user_id>/ssh-certificates", methods=["GET"])
@login_required
@full_access_required
def admin_get_user_ssh_certificates(user_id):
"""List all SSH certificates for a user (admin view).
Returns all certificates active, expired, revoked with relevant
metrics for admin visibility. Includes SSH key metadata (fingerprint,
type, description) via the ssh_key relationship.
Query parameters:
status: Filter by certificate status (issued, revoked, expired, superseded)
active: If "true", return only currently valid certificates
cert_type: Filter by certificate type (user, host)
"""
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ssh_ca.ca import CertType
caller = g.current_user
target = _find_user_for_admin(user_id)
if not target:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
if not _get_admin_access(caller, target):
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
query = SSHCertificate.query.filter_by(user_id=user_id, deleted_at=None)
# Filter by explicit status (e.g. ?status=revoked)
status_param = request.args.get("status", "").strip().lower()
if status_param:
try:
status_enum = CertificateStatus(status_param)
query = query.filter(SSHCertificate.status == status_enum)
except ValueError:
valid_statuses = [s.value for s in CertificateStatus]
return api_response(
success=False,
message=f"Invalid status '{status_param}'. Must be one of: {', '.join(valid_statuses)}",
status=400, error_type="VALIDATION_ERROR",
)
# Filter for only currently valid certs (?active=true)
active_param = request.args.get("active", "").strip().lower()
if active_param == "true":
now = datetime.now(timezone.utc)
query = query.filter(
SSHCertificate.revoked == False,
SSHCertificate.valid_after <= now,
SSHCertificate.valid_before >= now,
)
elif active_param == "false":
now = datetime.now(timezone.utc)
query = query.filter(
(SSHCertificate.revoked == True) |
(SSHCertificate.valid_before < now)
)
# Filter by certificate type (?cert_type=host)
cert_type_param = request.args.get("cert_type", "").strip().lower()
if cert_type_param:
try:
cert_type_enum = CertType(cert_type_param)
query = query.filter(SSHCertificate.cert_type == cert_type_enum)
except ValueError:
return api_response(
success=False,
message=f"Invalid cert_type '{cert_type_param}'. Must be one of: user, host",
status=400, error_type="VALIDATION_ERROR",
)
# Pagination
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(1, int(request.args.get("per_page", 50))))
except ValueError:
page, per_page = 1, 50
total = query.count()
certs = (
query.order_by(SSHCertificate.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
now = datetime.now(timezone.utc)
certs_data = []
for cert in certs:
d = cert.to_dict()
# Enrich with SSH key metadata
if cert.ssh_key:
d["ssh_key"] = {
"id": str(cert.ssh_key.id),
"fingerprint": cert.ssh_key.fingerprint,
"key_type": cert.ssh_key.key_type,
"key_bits": cert.ssh_key.key_bits,
"key_comment": cert.ssh_key.key_comment,
"description": cert.ssh_key.description,
"verified": cert.ssh_key.verified,
}
else:
d["ssh_key"] = None
certs_data.append(d)
return api_response(
data={
"user": {
"id": str(target.id),
"email": target.email,
"full_name": target.full_name,
},
"certificates": certs_data,
"count": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
},
message="SSH certificates retrieved successfully",
)
@api_v1_bp.route("/admin/users/<user_id>/linked-accounts", methods=["GET"]) @api_v1_bp.route("/admin/users/<user_id>/linked-accounts", methods=["GET"])
@login_required @login_required
@full_access_required @full_access_required
+277 -62
View File
@@ -1,8 +1,11 @@
"""ZeroTier network governance API endpoints.""" """ZeroTier network governance API endpoints."""
from datetime import datetime, timezone
from flask import g, request from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError from marshmallow import Schema, fields, validate, ValidationError
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from gatehouse_app.api.v1 import api_v1_bp from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
@@ -13,16 +16,16 @@ from gatehouse_app.services import device_service
from gatehouse_app.services import network_access_service from gatehouse_app.services import network_access_service
from gatehouse_app.services import zerotier_api_service as zt from gatehouse_app.services import zerotier_api_service as zt
from gatehouse_app.services import zerotier_reconciliation_service from gatehouse_app.services import zerotier_reconciliation_service
from gatehouse_app.services.user_service import UserService
from gatehouse_app.models import ( from gatehouse_app.models import (
PortalNetwork, PortalNetwork,
Device, Device,
DeviceNetworkMembership,
UserNetworkApproval,
ActivationSession, ActivationSession,
NetworkAccessRequest,
) )
from gatehouse_app.models.organization import Organization from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole, AuditAction
from gatehouse_app.exceptions import ( from gatehouse_app.exceptions import (
ValidationError as AppValidationError, ValidationError as AppValidationError,
ZeroTierAPIError, ZeroTierAPIError,
@@ -30,7 +33,6 @@ from gatehouse_app.exceptions import (
DeviceNotFoundError, DeviceNotFoundError,
DeviceAlreadyExistsError, DeviceAlreadyExistsError,
ApprovalNotFoundError, ApprovalNotFoundError,
MembershipNotFoundError,
) )
@@ -115,6 +117,10 @@ class KillSwitchSchema(Schema):
network_ids = fields.List(fields.Str(), allow_none=True) network_ids = fields.List(fields.Str(), allow_none=True)
class NetworkKillSwitchSchema(Schema):
reason = fields.Str(allow_none=True, validate=validate.Length(max=500))
# ── Networks ────────────────────────────────────────────────────────────────── # ── Networks ──────────────────────────────────────────────────────────────────
@@ -128,7 +134,11 @@ def list_networks(org_id):
return err return err
include_inactive = request.args.get("include_inactive", "false").lower() == "true" include_inactive = request.args.get("include_inactive", "false").lower() == "true"
networks = portal_network_service.list_networks(org_id, include_inactive=include_inactive) networks = portal_network_service.list_networks(
org_id,
include_inactive=include_inactive,
user_id=g.current_user.id,
)
return api_response( return api_response(
data={"networks": [n.to_dict() for n in networks], "count": len(networks)}, data={"networks": [n.to_dict() for n in networks], "count": len(networks)},
@@ -302,7 +312,7 @@ def get_network_members(org_id, network_id):
memberships = portal_network_service.get_network_members(network_id) memberships = portal_network_service.get_network_members(network_id)
return api_response( return api_response(
data={"memberships": [m.to_dict() for m in memberships], "count": len(memberships)}, data={"memberships": memberships, "count": len(memberships)},
message="Network members retrieved successfully", message="Network members retrieved successfully",
) )
@@ -347,6 +357,47 @@ def list_devices(org_id):
) )
@api_v1_bp.route("/organizations/<org_id>/users/<user_id>/devices", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_user_devices(org_id, user_id):
"""List all ZeroTier devices for a specific user in the organization (admin only)."""
org, err = _org_check(org_id)
if err:
return err
# Verify target user exists
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
try:
target_user = UserService.get_user_by_id(user_id)
except UserNotFoundError:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
# Verify target user is a member of the org
is_member = OrganizationMember.query.filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
OrganizationMember.deleted_at.is_(None),
).first() is not None
if not is_member:
return api_response(success=False, message="User is not a member of this organization", status=404, error_type="NOT_FOUND")
# Get devices for the user in this org
devices = device_service.list_user_devices(user_id, org_id)
return api_response(
data={
"devices": [d.to_dict() for d in devices],
"count": len(devices),
"user_id": user_id,
"organization_id": org_id,
},
message="User devices retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/devices", methods=["POST"]) @api_v1_bp.route("/organizations/<org_id>/devices", methods=["POST"])
@login_required @login_required
@full_access_required @full_access_required
@@ -373,11 +424,8 @@ def register_device(org_id):
serial_number=data.get("serial_number"), serial_number=data.get("serial_number"),
) )
from gatehouse_app.services.network_access_service import materialize_device_memberships
memberships = materialize_device_memberships(device.id)
return api_response( return api_response(
data={"device": device.to_dict(), "memberships_created": len(memberships)}, data={"device": device.to_dict()},
message="Device registered successfully", message="Device registered successfully",
status=201, status=201,
) )
@@ -486,7 +534,7 @@ def list_my_approvals(org_id):
if err: if err:
return err return err
approvals = network_access_service.list_user_approvals(g.current_user.id, org_id) approvals = network_access_service.list_user_requests(g.current_user.id, org_id)
return api_response( return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)}, data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully", message="Approvals retrieved successfully",
@@ -549,18 +597,18 @@ def reject_request(org_id, approval_id):
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/approvals/<approval_id>/revoke", methods=["POST"]) @api_v1_bp.route("/organizations/<org_id>/approvals/<request_id>/revoke", methods=["POST"])
@login_required @login_required
@require_admin @require_admin
@full_access_required @full_access_required
def revoke_approval(org_id, approval_id): def revoke_approval(org_id, request_id):
"""Revoke an approved access record (admin only).""" """Revoke an approved access record (admin only)."""
org, err = _org_check(org_id) org, err = _org_check(org_id)
if err: if err:
return err return err
try: try:
approval = network_access_service.revoke_approval(approval_id, g.current_user.id) approval = network_access_service.revoke_access(request_id, g.current_user.id)
return api_response(data={"approval": approval.to_dict()}, message="Approval revoked successfully") return api_response(data={"approval": approval.to_dict()}, message="Approval revoked successfully")
except ApprovalNotFoundError as e: except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type) return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@@ -607,7 +655,7 @@ def admin_list_all_approvals(org_id):
network_id = request.args.get("network_id") network_id = request.args.get("network_id")
state = request.args.get("state") state = request.args.get("state")
approvals = network_access_service.list_all_org_approvals(org_id, network_id=network_id, state=state) approvals = network_access_service.list_all_org_requests(org_id, network_id=network_id, state=state)
return api_response( return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)}, data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully", message="Approvals retrieved successfully",
@@ -626,10 +674,10 @@ def list_memberships(org_id):
if err: if err:
return err return err
memberships = DeviceNetworkMembership.query.filter( memberships = NetworkAccessRequest.query.filter(
DeviceNetworkMembership.user_id == g.current_user.id, NetworkAccessRequest.user_id == g.current_user.id,
DeviceNetworkMembership.organization_id == org_id, NetworkAccessRequest.organization_id == org_id,
DeviceNetworkMembership.deleted_at.is_(None), NetworkAccessRequest.deleted_at.is_(None),
).all() ).all()
return api_response( return api_response(
@@ -656,15 +704,14 @@ def activate_membership(org_id, membership_id):
is_admin = _is_org_admin(org_id, g.current_user.id) is_admin = _is_org_admin(org_id, g.current_user.id)
try: try:
session = network_access_service.activate_device_membership( session = network_access_service.activate_request(
membership_id=membership_id, request_id=membership_id,
user_id=g.current_user.id, user_id=g.current_user.id,
lifetime_minutes=data.get("lifetime_minutes"), lifetime_minutes=data.get("lifetime_minutes"),
admin_override=is_admin, admin_override=is_admin,
) )
membership = DeviceNetworkMembership.query.get(membership_id) return api_response(data={"session": session.to_dict()}, message="Request activated successfully")
return api_response(data={"session": session.to_dict(), "membership": membership.to_dict()}, message="Membership activated successfully") except ApprovalNotFoundError as e:
except MembershipNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type) return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
except AppValidationError as e: except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@@ -681,22 +728,22 @@ def deactivate_membership(org_id, membership_id):
# Verify ownership for non-admins # Verify ownership for non-admins
if not _is_org_admin(org_id, g.current_user.id): if not _is_org_admin(org_id, g.current_user.id):
membership_check = DeviceNetworkMembership.query.filter( membership_check = NetworkAccessRequest.query.filter(
DeviceNetworkMembership.id == membership_id, NetworkAccessRequest.id == membership_id,
DeviceNetworkMembership.user_id == g.current_user.id, NetworkAccessRequest.user_id == g.current_user.id,
DeviceNetworkMembership.deleted_at.is_(None), NetworkAccessRequest.deleted_at.is_(None),
).first() ).first()
if not membership_check: if not membership_check:
return api_response(success=False, message="Membership not found", status=404, error_type="NOT_FOUND") return api_response(success=False, message="Membership not found", status=404, error_type="NOT_FOUND")
try: try:
membership = network_access_service.deactivate_membership( req = network_access_service.deactivate_request(
membership_id=membership_id, request_id=membership_id,
reason="manual_revoke", reason="manual_revoke",
deactivated_by_user_id=g.current_user.id, deactivated_by_user_id=g.current_user.id,
) )
return api_response(data={"membership": membership.to_dict()}, message="Membership deactivated successfully") return api_response(data={"request": req.to_dict()}, message="Request deactivated successfully")
except MembershipNotFoundError as e: except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type) return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@@ -730,17 +777,21 @@ def activate_all_memberships(org_id):
@login_required @login_required
@full_access_required @full_access_required
def join_network(org_id, device_id, portal_network_id): def join_network(org_id, device_id, portal_network_id):
"""Join an open network directly with a registered device.""" """Join an open network directly with a registered device. Admins can override for any network."""
org, err = _org_check(org_id) org, err = _org_check(org_id)
if err: if err:
return err return err
is_admin = _is_org_admin(org_id, g.current_user.id)
try: try:
membership = network_access_service.join_network_for_device( membership = network_access_service.join_network_for_device(
user_id=g.current_user.id, user_id=g.current_user.id,
organization_id=org_id, organization_id=org_id,
device_id=device_id, device_id=device_id,
portal_network_id=portal_network_id, portal_network_id=portal_network_id,
admin_override=is_admin,
granted_by_user_id=g.current_user.id if is_admin else None,
) )
return api_response(data={"membership": membership.to_dict()}, message="Joined network successfully", status=201) return api_response(data={"membership": membership.to_dict()}, message="Joined network successfully", status=201)
except AppValidationError as e: except AppValidationError as e:
@@ -759,15 +810,71 @@ def delete_membership(org_id, membership_id):
return err return err
try: try:
network_access_service.revoke_membership_soft( network_access_service.revoke_request_soft(
membership_id=membership_id, request_id=membership_id,
revoked_by_user_id=g.current_user.id, revoker_user_id=g.current_user.id,
) )
return api_response(message="Membership removed successfully") return api_response(message="Request revoked successfully")
except MembershipNotFoundError as e: except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type) return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
# ── Session helpers ──────────────────────────────────────────────────────────
def _session_to_dict(session, include_user=False):
"""Build a rich session dict with device, network, and timing details."""
now = datetime.now(timezone.utc)
exp = session.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
remaining = (exp - now).total_seconds() if exp > now else 0
duration = (session.expires_at - session.authenticated_at).total_seconds()
auth_at = session.authenticated_at
if auth_at.tzinfo is None:
auth_at = auth_at.replace(tzinfo=timezone.utc)
exp_at = session.expires_at
if exp_at.tzinfo is None:
exp_at = exp_at.replace(tzinfo=timezone.utc)
d = {
"id": session.id,
"authenticated_at": auth_at.isoformat(),
"expires_at": exp_at.isoformat(),
"duration_seconds": int(duration),
"remaining_seconds": max(0, int(remaining)),
"is_active": session.is_active,
"is_expired": session.is_expired,
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
"end_reason": session.end_reason.value if session.end_reason else None,
}
if session.access_request:
if session.access_request.device:
dev = session.access_request.device
d["device"] = {
"id": dev.id,
"node_id": dev.node_id,
"name": dev.display_name,
}
if session.access_request.portal_network:
net = session.access_request.portal_network
d["network"] = {
"id": net.id,
"name": net.name,
}
if include_user:
d["user"] = {
"id": session.user.id,
"full_name": session.user.full_name,
"email": session.user.email,
}
return d
# ── Sessions ───────────────────────────────────────────────────────────────── # ── Sessions ─────────────────────────────────────────────────────────────────
@@ -780,15 +887,27 @@ def list_sessions(org_id):
if err: if err:
return err return err
sessions = ActivationSession.query.filter( sessions = (
ActivationSession.query.options(
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.device),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.portal_network),
)
.filter(
ActivationSession.user_id == g.current_user.id, ActivationSession.user_id == g.current_user.id,
ActivationSession.organization_id == org_id, ActivationSession.organization_id == org_id,
ActivationSession.ended_at.is_(None), ActivationSession.ended_at.is_(None),
ActivationSession.deleted_at.is_(None), ActivationSession.deleted_at.is_(None),
).all() )
.all()
)
return api_response( return api_response(
data={"sessions": [s.to_dict() for s in sessions], "count": len(sessions)}, data={
"sessions": [_session_to_dict(s) for s in sessions],
"count": len(sessions),
},
message="Sessions retrieved successfully", message="Sessions retrieved successfully",
) )
@@ -816,18 +935,80 @@ def end_session(org_id, session_id):
from gatehouse_app.services.network_access_service import _end_session from gatehouse_app.services.network_access_service import _end_session
from gatehouse_app.utils.constants import ActivationEndReason from gatehouse_app.utils.constants import ActivationEndReason
from datetime import datetime, timezone
_end_session(session, ActivationEndReason.LOGOUT) _end_session(session, ActivationEndReason.LOGOUT)
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id) if session.network_access_request_id:
if membership: network_access_service.deactivate_request(session.network_access_request_id, reason="logout")
from gatehouse_app.services.network_access_service import deactivate_membership
deactivate_membership(membership.id, reason="logout")
return api_response(message="Session ended successfully") return api_response(message="Session ended successfully")
@api_v1_bp.route("/organizations/<org_id>/admin/sessions", methods=["GET"])
@login_required
@require_admin
@full_access_required
def admin_list_sessions(org_id):
"""List all active activation sessions across all users (admin only)."""
org, err = _org_check(org_id)
if err:
return err
sessions = (
ActivationSession.query.options(
joinedload(ActivationSession.user),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.device),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.portal_network),
)
.filter(
ActivationSession.organization_id == org_id,
ActivationSession.ended_at.is_(None),
ActivationSession.deleted_at.is_(None),
)
.all()
)
return api_response(
data={
"sessions": [_session_to_dict(s, include_user=True) for s in sessions],
"count": len(sessions),
},
message="Admin sessions retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/admin/sessions/<session_id>/end", methods=["POST"])
@login_required
@require_admin
@full_access_required
def admin_end_session(org_id, session_id):
"""End a specific activation session (admin only).
Terminates the active session for any user, deauthorizes the device
in ZeroTier, and marks the membership as inactive. The user retains
their approval and can re-authenticate without re-approval.
"""
org, err = _org_check(org_id)
if err:
return err
try:
session = network_access_service.admin_end_session(
session_id=session_id,
admin_user_id=g.current_user.id,
)
return api_response(
data={"session": _session_to_dict(session, include_user=True)},
message="Session ended successfully by admin",
)
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type="NOT_FOUND")
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
# ── Kill Switch ─────────────────────────────────────────────────────────────── # ── Kill Switch ───────────────────────────────────────────────────────────────
@@ -848,19 +1029,44 @@ def trigger_kill_switch(org_id):
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try: try:
event = network_access_service.kill_switch( from gatehouse_app.utils.constants import KillSwitchScope
target_user_id=data["target_user_id"], scope = data.get("scope", "organization")
triggered_by_user_id=g.current_user.id, scope_enum = KillSwitchScope(scope) if scope in KillSwitchScope._value2member_map_ else KillSwitchScope.ORGANIZATION
organization_id=org_id, count = network_access_service.kill_switch(
scope=data.get("scope", "organization"), user_id=data["target_user_id"],
reason=data.get("reason"), org_id=org_id,
scope=scope_enum,
network_ids=data.get("network_ids"), network_ids=data.get("network_ids"),
) )
return api_response(data={"event": event.to_dict()}, message="Kill switch triggered successfully") return api_response(data={"affected_count": count}, message="Kill switch triggered successfully")
except AppValidationError as e: except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>/kill-switch", methods=["POST"])
@login_required
@require_admin
@full_access_required
def trigger_network_kill_switch(org_id, network_id):
"""Deactivate all active memberships on a network (admin only)."""
org, err = _org_check(org_id)
if err:
return err
schema = NetworkKillSwitchSchema()
data = schema.load(request.json or {})
count = network_access_service.kill_switch_network(
portal_network_id=network_id,
organization_id=org_id,
admin_user_id=g.current_user.id,
)
return api_response(
data={"affected_count": count},
message="Network kill switch triggered successfully",
)
# ── Admin / ZeroTier Controller ─────────────────────────────────────────────── # ── Admin / ZeroTier Controller ───────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/admin/memberships", methods=["GET"]) @api_v1_bp.route("/organizations/<org_id>/admin/memberships", methods=["GET"])
@@ -873,10 +1079,10 @@ def admin_list_memberships(org_id):
if err: if err:
return err return err
memberships = network_access_service.get_all_memberships_with_details(org_id) requests = network_access_service.get_all_requests_with_details(org_id)
return api_response( return api_response(
data={"memberships": memberships, "count": len(memberships)}, data={"requests": requests, "count": len(requests)},
message="All memberships retrieved successfully", message="All requests retrieved successfully",
) )
@@ -885,16 +1091,25 @@ def admin_list_memberships(org_id):
@require_admin @require_admin
@full_access_required @full_access_required
def admin_delete_membership(org_id, membership_id): def admin_delete_membership(org_id, membership_id):
"""Hard-delete a membership and remove it from ZeroTier (admin only).""" """Force-delete a membership and remove it from ZeroTier (admin only).
Handles the full lifecycle: deactivates if active, removes the member
from the ZeroTier controller, and hard-deletes the DB record.
"""
org, err = _org_check(org_id) org, err = _org_check(org_id)
if err: if err:
return err return err
try: try:
network_access_service.hard_delete_membership(membership_id) network_access_service.admin_force_delete_request(
return api_response(message="Membership permanently deleted") membership_id,
except MembershipNotFoundError as e: admin_user_id=g.current_user.id,
)
return api_response(message="Request permanently deleted")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type) return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
# ── ZeroTier Controller ─────────────────────────────────────────────────────── # ── ZeroTier Controller ───────────────────────────────────────────────────────
@@ -1115,7 +1330,7 @@ def set_zerotier_config(org_id):
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
AuditService.log_action( AuditService.log_action(
action="org.zerotier_config.updated", action=AuditAction.ZT_CONFIG_UPDATED,
user_id=g.current_user.id, user_id=g.current_user.id,
organization_id=org_id, organization_id=org_id,
resource_type="organization", resource_type="organization",
@@ -1167,7 +1382,7 @@ def delete_zerotier_config(org_id):
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
AuditService.log_action( AuditService.log_action(
action="org.zerotier_config.deleted", action=AuditAction.ZT_CONFIG_DELETED,
user_id=g.current_user.id, user_id=g.current_user.id,
organization_id=org_id, organization_id=org_id,
resource_type="organization", resource_type="organization",
+10 -6
View File
@@ -15,7 +15,7 @@ def superadmin_required(f):
"""Decorator to require superadmin Bearer token authentication. """Decorator to require superadmin Bearer token authentication.
Extracts token from Authorization: Bearer {token} header, 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. and sets g.current_superadmin and g.superadmin_session.
Returns 401 if no valid session, 403 if not a superadmin. Returns 401 if no valid session, 403 if not a superadmin.
@@ -46,10 +46,14 @@ def superadmin_required(f):
token = parts[1] token = parts[1]
# Import here to avoid circular imports # 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 # Get active session by token, scoped to superadmin
session = SuperadminSession.query.filter_by(token=token).first() session = Session.query.filter_by(
token=token, owner_type=SessionType.SUPERADMIN
).first()
if not session: if not session:
return api_response( return api_response(
@@ -68,8 +72,8 @@ def superadmin_required(f):
error_type="SESSION_INACTIVE" error_type="SESSION_INACTIVE"
) )
# Get the superadmin # Get the superadmin by owner_id
superadmin = session.superadmin superadmin = Superadmin.query.get(session.owner_id)
if not superadmin: if not superadmin:
return api_response( return api_response(
success=False, success=False,
+72 -11
View File
@@ -23,9 +23,10 @@ from gatehouse_app.extensions import db
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user.user import User from gatehouse_app.models.user.user import User
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.notification_service import NotificationService from gatehouse_app.services.notification_service import NotificationService
from gatehouse_app.utils.constants import MfaComplianceStatus from gatehouse_app.utils.constants import MfaComplianceStatus, OrganizationRole
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,9 +36,11 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
This scheduled job performs the following operations: This scheduled job performs the following operations:
1. Transitions users from PAST_DUE to SUSPENDED status 1. Transitions users from PAST_DUE to SUSPENDED status
2. Identifies users approaching deadline (within notify_days_before) 2. Sends suspension notification to suspended users
3. Sends deadline reminder notifications 3. Sends suspension notification to org admins
4. Updates notification tracking metadata 4. Identifies users approaching deadline (within notify_days_before)
5. Sends deadline reminder notifications
6. Updates notification tracking metadata
Args: Args:
now: Current time, defaults to now (UTC) now: Current time, defaults to now (UTC)
@@ -45,7 +48,9 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
Returns: Returns:
Dictionary with job execution statistics: Dictionary with job execution statistics:
- suspended_count: Number of users transitioned to suspended - suspended_count: Number of users transitioned to suspended
- notified_count: Number of notifications sent - user_notified_count: Number of suspension emails sent to users
- admin_notified_count: Number of suspension emails sent to admins
- notified_count: Number of deadline reminder notifications sent
- processed_count: Total compliance records processed - processed_count: Total compliance records processed
""" """
if now is None: if now is None:
@@ -55,6 +60,8 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
stats = { stats = {
"suspended_count": 0, "suspended_count": 0,
"user_notified_count": 0,
"admin_notified_count": 0,
"notified_count": 0, "notified_count": 0,
"processed_count": 0, "processed_count": 0,
"errors": [], "errors": [],
@@ -62,16 +69,67 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
try: try:
# Step 1: Transition past-due users to suspended # Step 1: Transition past-due users to suspended
suspended_count = MfaPolicyService.transition_to_suspended_if_past_due(now) suspended_records = MfaPolicyService.transition_to_suspended_if_past_due(now)
stats["suspended_count"] = suspended_count stats["suspended_count"] = len(suspended_records)
logger.info(f"Transitioned {suspended_count} users to suspended status") logger.info(f"Transitioned {len(suspended_records)} users to suspended status")
# Step 2: Send notifications to users approaching deadline # Step 2: Send notifications for each suspended user
for entry in suspended_records:
try:
user = entry["user"]
compliance = entry["compliance"]
org_policy = entry["org_policy"]
# 2a: Send suspension notification to the user
user_notified = NotificationService.send_mfa_suspended_notification(
user=user,
compliance=compliance,
org_policy=org_policy,
)
if user_notified:
stats["user_notified_count"] += 1
logger.info(f"Sent suspension notice to user {user.email}")
# 2b: Send suspension notification to org admins
if org_policy:
admin_members = OrganizationMember.query.filter(
OrganizationMember.organization_id == compliance.organization_id,
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
OrganizationMember.deleted_at == None,
).all()
for member in admin_members:
admin_user = User.query.get(member.user_id)
if not admin_user or not admin_user.email:
continue
# Skip notifying the suspended user themselves
if admin_user.id == user.id:
continue
admin_notified = NotificationService.send_mfa_suspended_admin_notification(
admin_user=admin_user,
suspended_user=user,
compliance=compliance,
org_policy=org_policy,
)
if admin_notified:
stats["admin_notified_count"] += 1
except Exception as e:
logger.warning(
f"Error sending suspension notifications for compliance record "
f"{entry.get('compliance', {}).id if entry.get('compliance') else 'unknown'}: {e}"
)
stats["errors"].append(str(e))
continue
# Step 3: Send notifications to users approaching deadline
notified_count = _send_deadline_reminders(now) notified_count = _send_deadline_reminders(now)
stats["notified_count"] = notified_count stats["notified_count"] = notified_count
logger.info(f"Sent {notified_count} deadline reminder notifications") logger.info(f"Sent {notified_count} deadline reminder notifications")
# Step 3: Process any pending compliance evaluations # Step 4: Process any pending compliance evaluations
processed_count = _evaluate_pending_compliance(now) processed_count = _evaluate_pending_compliance(now)
stats["processed_count"] = processed_count stats["processed_count"] = processed_count
logger.info(f"Processed {processed_count} compliance records") logger.info(f"Processed {processed_count} compliance records")
@@ -82,7 +140,10 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
logger.info( logger.info(
f"MFA compliance job completed: suspended={stats['suspended_count']}, " f"MFA compliance job completed: suspended={stats['suspended_count']}, "
f"notified={stats['notified_count']}, processed={stats['processed_count']}" f"user_notified={stats['user_notified_count']}, "
f"admin_notified={stats['admin_notified_count']}, "
f"deadline_reminders={stats['notified_count']}, "
f"processed={stats['processed_count']}"
) )
return stats return stats
@@ -44,6 +44,10 @@ def run_reconciliation() -> dict:
results = { results = {
"expired_activations": 0, "expired_activations": 0,
"networks_processed": 0, "networks_processed": 0,
"authorized": 0,
"deauthorized": 0,
"deleted_memberships": 0,
"delete_errors": 0,
"errors": 0, "errors": 0,
} }
@@ -56,6 +60,10 @@ def run_reconciliation() -> dict:
try: try:
summary = zerotier_reconciliation_service.reconcile_all() summary = zerotier_reconciliation_service.reconcile_all()
results["networks_processed"] = summary.get("networks_processed", 0) results["networks_processed"] = summary.get("networks_processed", 0)
results["authorized"] = summary.get("authorized", 0)
results["deauthorized"] = summary.get("deauthorized", 0)
results["deleted_memberships"] = summary.get("deleted_memberships", 0)
results["delete_errors"] = summary.get("delete_errors", 0)
results["errors"] = summary.get("errors", 0) results["errors"] = summary.get("errors", 0)
except Exception as exc: except Exception as exc:
logger.error(f"[ZT Reconcile] Error during network reconciliation: {exc}") logger.error(f"[ZT Reconcile] Error during network reconciliation: {exc}")
@@ -65,6 +73,9 @@ def run_reconciliation() -> dict:
f"[ZT Reconcile] Complete at {datetime.now(timezone.utc).isoformat()}: " f"[ZT Reconcile] Complete at {datetime.now(timezone.utc).isoformat()}: "
f"expired={results['expired_activations']} " f"expired={results['expired_activations']} "
f"networks={results['networks_processed']} " f"networks={results['networks_processed']} "
f"authorized={results['authorized']} "
f"deauthorized={results['deauthorized']} "
f"purged={results['deleted_memberships']} "
f"errors={results['errors']}" f"errors={results['errors']}"
) )
+87 -2
View File
@@ -1,6 +1,12 @@
"""CORS middleware configuration.""" """CORS middleware configuration."""
import base64
import json
from urllib.parse import parse_qs
from flask import request, make_response from flask import request, make_response
from gatehouse_app.models import OIDCClient
ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS" ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
ALLOWED_HEADERS = ( ALLOWED_HEADERS = (
"Content-Type, Authorization, X-Requested-With, X-Request-ID, " "Content-Type, Authorization, X-Requested-With, X-Request-ID, "
@@ -40,6 +46,85 @@ def _cors_origin_header(cors_origins, request_origin):
return None 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): def setup_cors(app):
""" """
Configure CORS for the application. Configure CORS for the application.
@@ -54,7 +139,7 @@ def setup_cors(app):
"""Handle CORS preflight OPTIONS requests.""" """Handle CORS preflight OPTIONS requests."""
if request.method == "OPTIONS": if request.method == "OPTIONS":
origin = request.headers.get("Origin") 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): if not _is_origin_allowed(origin, cors_origins):
return None return None
@@ -73,7 +158,7 @@ def setup_cors(app):
def after_request_cors(response): def after_request_cors(response):
"""Add CORS headers to non-preflight responses.""" """Add CORS headers to non-preflight responses."""
origin = request.headers.get("Origin") 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) allow_origin = _cors_origin_header(cors_origins, origin)
if allow_origin: if allow_origin:
+18 -16
View File
@@ -17,9 +17,8 @@ models.ssh_ca — CA, KeyType, CertType, CaType, CAPermission,
CertificateAuditLog CertificateAuditLog
models.security OrganizationSecurityPolicy, UserSecurityPolicy, models.security OrganizationSecurityPolicy, UserSecurityPolicy,
MfaPolicyCompliance MfaPolicyCompliance
models.zerotier PortalNetwork, Device, UserNetworkApproval, models.zerotier PortalNetwork, Device, NetworkAccessRequest,
DeviceNetworkMembership, ActivationSession, ActivationSession, ZeroTierMembership, KillSwitchEvent
ZeroTierMembership, KillSwitchEvent
All names are re-exported here so that existing code using the flat import All names are re-exported here so that existing code using the flat import
style (``from gatehouse_app.models import X``) or the old per-file style style (``from gatehouse_app.models import X``) or the old per-file style
@@ -103,22 +102,15 @@ from gatehouse_app.models.security.mfa_policy_compliance import (
MfaPolicyCompliance, MfaPolicyCompliance,
) )
# ── ZeroTier / Portal Network ───────────────────────────────────────────────── # ── External Auth ─────────────────────────────────────────────────────────────
from gatehouse_app.models.zerotier import ( # noqa: F401 from gatehouse_app.services.external_auth.models import ( # noqa: F401
PortalNetwork, ExternalProviderConfig,
Device,
UserNetworkApproval,
DeviceNetworkMembership,
ActivationSession,
ZeroTierMembership,
KillSwitchEvent,
) )
# ── Superadmin ───────────────────────────────────────────────────────────────── # ── Superadmin ─────────────────────────────────────────────────────────────────
from gatehouse_app.models.superadmin import ( # noqa: F401 from gatehouse_app.models.superadmin import ( # noqa: F401
Superadmin, Superadmin,
SuperadminSession, SuperadminSession,
SuperadminSessionStatus,
) )
from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog # noqa: F401 from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog # noqa: F401
from gatehouse_app.models.security.user_security_policy import ( # noqa: F401 from gatehouse_app.models.security.user_security_policy import ( # noqa: F401
@@ -128,6 +120,16 @@ from gatehouse_app.models.security.mfa_policy_compliance import ( # noqa: F401
MfaPolicyCompliance, MfaPolicyCompliance,
) )
# ── ZeroTier ──────────────────────────────────────────────────────────────
from gatehouse_app.models.zerotier import ( # noqa: F401
PortalNetwork,
Device,
NetworkAccessRequest,
ActivationSession,
ZeroTierMembership,
KillSwitchEvent,
)
__all__ = [ __all__ = [
# Base # Base
"BaseModel", "BaseModel",
@@ -175,17 +177,17 @@ __all__ = [
"OrganizationSecurityPolicy", "OrganizationSecurityPolicy",
"UserSecurityPolicy", "UserSecurityPolicy",
"MfaPolicyCompliance", "MfaPolicyCompliance",
# External Auth
"ExternalProviderConfig",
# ZeroTier # ZeroTier
"PortalNetwork", "PortalNetwork",
"Device", "Device",
"UserNetworkApproval", "NetworkAccessRequest",
"DeviceNetworkMembership",
"ActivationSession", "ActivationSession",
"ZeroTierMembership", "ZeroTierMembership",
"KillSwitchEvent", "KillSwitchEvent",
# Superadmin # Superadmin
"Superadmin", "Superadmin",
"SuperadminSession", "SuperadminSession",
"SuperadminSessionStatus",
"SuperadminAuditLog", "SuperadminAuditLog",
] ]
-17
View File
@@ -1,7 +1,6 @@
"""Audit log model.""" """Audit log model."""
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.utils.constants import AuditAction
class AuditLog(BaseModel): class AuditLog(BaseModel):
@@ -42,19 +41,3 @@ class AuditLog(BaseModel):
def __repr__(self): def __repr__(self):
"""String representation of AuditLog.""" """String representation of AuditLog."""
return f"<AuditLog action={self.action} user_id={self.user_id}>" return f"<AuditLog action={self.action} user_id={self.user_id}>"
@classmethod
def log(cls, action, user_id=None, **kwargs) -> "AuditLog":
"""Create an audit log entry.
Args:
action: AuditAction enum value
user_id: ID of the user performing the action
**kwargs: Additional audit log fields
Returns:
AuditLog instance
"""
log_entry = cls(action=action, user_id=user_id, **kwargs)
log_entry.save()
return log_entry
-1
View File
@@ -13,7 +13,6 @@ class BaseModel(db.Model):
db.String(36), db.String(36),
primary_key=True, primary_key=True,
default=lambda: str(uuid.uuid4()), default=lambda: str(uuid.uuid4()),
unique=True,
nullable=False, nullable=False,
) )
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+34
View File
@@ -1,4 +1,6 @@
"""OIDC Client model.""" """OIDC Client model."""
from urllib.parse import urlparse
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.utils.constants import OIDCGrantType, OIDCResponseType 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 grant_types = db.Column(db.JSON, nullable=False) # Allowed grant types
response_types = db.Column(db.JSON, nullable=False) # Allowed response types response_types = db.Column(db.JSON, nullable=False) # Allowed response types
scopes = db.Column(db.JSON, nullable=False) # Allowed scopes 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 # Client metadata
logo_uri = db.Column(db.String(512), nullable=True) 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.""" """Check if a redirect URI is allowed for this client."""
return redirect_uri in self.redirect_uris 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: def has_scope(self, scope: str) -> bool:
"""Check if client is allowed to request a specific scope.""" """Check if client is allowed to request a specific scope."""
return scope in self.scopes return scope in self.scopes
@@ -12,7 +12,6 @@ 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",
@@ -25,5 +24,4 @@ __all__ = [
"Principal", "Principal",
"PrincipalMembership", "PrincipalMembership",
"OrgInviteToken", "OrgInviteToken",
"OrganizationApiKey",
] ]
@@ -27,7 +27,6 @@ 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")
@@ -47,9 +47,6 @@ 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."""
@@ -71,11 +68,18 @@ class Organization(BaseModel):
def is_member(self, user_id: str) -> bool: def is_member(self, user_id: str) -> bool:
"""Check if a user is a member of the organization.""" """Check if a user is a member of the organization."""
from gatehouse_app.models.organization.organization_member import OrganizationMember from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user.user import User
return ( return (
OrganizationMember.query.filter_by( db.session.query(OrganizationMember)
user_id=user_id, organization_id=self.id, deleted_at=None .join(User, OrganizationMember.user_id == User.id)
).first() .filter(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == self.id,
OrganizationMember.deleted_at.is_(None),
User.deleted_at.is_(None),
)
.first()
is not None is not None
) )
def get_active_members(self): def get_active_members(self):
@@ -110,11 +114,3 @@ class Organization(BaseModel):
""" """
return [ca for ca in self.cas if ca.deleted_at is None] return [ca for ca in self.cas if ca.deleted_at is None]
def get_active_api_keys(self):
"""Get active (non-deleted) API keys.
Returns:
List of OrganizationApiKey instances where deleted_at is None.
"""
return [k for k in self.api_keys if k.deleted_at is None]
@@ -1,158 +0,0 @@
"""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)
+2 -2
View File
@@ -1,5 +1,5 @@
"""Superadmin models.""" """Superadmin models."""
from gatehouse_app.models.superadmin.superadmin import Superadmin 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) is_active = db.Column(db.Boolean, default=True, nullable=False)
last_login_at = db.Column(db.DateTime, nullable=True) 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( sessions = db.relationship(
"SuperadminSession", "Session",
back_populates="superadmin", primaryjoin=(
cascade="all, delete-orphan" "and_(Superadmin.id == foreign(Session.owner_id), "
"Session.owner_type == 'superadmin')"
),
cascade="all, delete-orphan",
lazy="dynamic",
) )
# Relationship to audit logs # 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 flask import current_app
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.utils.constants import SessionStatus from gatehouse_app.utils.constants import SessionStatus, SessionType
class Session(BaseModel): class Session(BaseModel):
"""Session model for tracking user sessions.""" """Session model for tracking user and superadmin sessions."""
__tablename__ = "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) token = db.Column(db.String(255), unique=True, nullable=False, index=True)
status = db.Column(db.Enum(SessionStatus), default=SessionStatus.ACTIVE, nullable=False) status = db.Column(db.Enum(SessionStatus), default=SessionStatus.ACTIVE, nullable=False)
@@ -34,21 +43,37 @@ class Session(BaseModel):
# Relationships # Relationships
user = db.relationship("User", back_populates="sessions") 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): def __repr__(self):
"""String representation of Session.""" return f"<Session owner_type={self.owner_type} owner_id={self.owner_id} status={self.status}>"
return f"<Session user_id={self.user_id} status={self.status}>"
def is_active(self): def is_active(self):
"""Check if session is currently active. """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 - Idle timeout: expires if no request has been made within
SESSION_IDLE_TIMEOUT seconds (default 15 min). SESSION_IDLE_TIMEOUT seconds (default 15 min).
- Absolute timeout: expires if SESSION_ABSOLUTE_TIMEOUT seconds - Absolute timeout: expires if SESSION_ABSOLUTE_TIMEOUT seconds
have elapsed since the session was created (default 8 h), have elapsed since the session was created (default 8 h).
regardless of activity.
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) now = datetime.now(timezone.utc)
created_at = self.created_at created_at = self.created_at
@@ -59,12 +84,21 @@ class Session(BaseModel):
if last_activity_at.tzinfo is None: if last_activity_at.tzinfo is None:
last_activity_at = last_activity_at.replace(tzinfo=timezone.utc) 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) 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) 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 ( return (
self.status == SessionStatus.ACTIVE self.status == SessionStatus.ACTIVE
and now < idle_expires_at and now < idle_expires_at
@@ -83,6 +117,8 @@ class Session(BaseModel):
capped so that the session never exceeds the absolute lifetime capped so that the session never exceeds the absolute lifetime
(``created_at + absolute timeout``). (``created_at + absolute timeout``).
Superadmin sessions only update last_activity_at (no sliding window).
Args: Args:
duration_seconds: Override for the idle timeout. When *None* duration_seconds: Override for the idle timeout. When *None*
(the common case), the value is read from (the common case), the value is read from
@@ -90,6 +126,12 @@ class Session(BaseModel):
""" """
now = datetime.now(timezone.utc) 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: if duration_seconds is None:
duration_seconds = current_app.config.get("SESSION_IDLE_TIMEOUT", 900) duration_seconds = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
+2 -4
View File
@@ -2,8 +2,7 @@
PortalNetwork manager-created network bound to a ZT network ID PortalNetwork manager-created network bound to a ZT network ID
Device user-registered ZeroTier node endpoint Device user-registered ZeroTier node endpoint
UserNetworkApproval durable manager approval for network access NetworkAccessRequest unified per-device, per-network access record
DeviceNetworkMembership per-device per-network workflow record
ActivationSession temporary activation window ActivationSession temporary activation window
ZeroTierMembership observed controller-side member state ZeroTierMembership observed controller-side member state
KillSwitchEvent explicit rapid deactivation record KillSwitchEvent explicit rapid deactivation record
@@ -11,8 +10,7 @@ KillSwitchEvent — explicit rapid deactivation record
from gatehouse_app.models.zerotier.activation_session import ActivationSession # noqa: F401 from gatehouse_app.models.zerotier.activation_session import ActivationSession # noqa: F401
from gatehouse_app.models.zerotier.device import Device # noqa: F401 from gatehouse_app.models.zerotier.device import Device # noqa: F401
from gatehouse_app.models.zerotier.device_network_membership import DeviceNetworkMembership # noqa: F401
from gatehouse_app.models.zerotier.kill_switch_event import KillSwitchEvent # noqa: F401 from gatehouse_app.models.zerotier.kill_switch_event import KillSwitchEvent # noqa: F401
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest # noqa: F401
from gatehouse_app.models.zerotier.portal_network import PortalNetwork # noqa: F401 from gatehouse_app.models.zerotier.portal_network import PortalNetwork # noqa: F401
from gatehouse_app.models.zerotier.user_network_approval import UserNetworkApproval # noqa: F401
from gatehouse_app.models.zerotier.zerotier_membership import ZeroTierMembership # noqa: F401 from gatehouse_app.models.zerotier.zerotier_membership import ZeroTierMembership # noqa: F401
@@ -16,7 +16,7 @@ class ActivationSession(BaseModel):
Attributes: Attributes:
organization_id: FK to the organization organization_id: FK to the organization
user_id: FK to the user who owns the session user_id: FK to the user who owns the session
device_network_membership_id: FK to the related membership network_access_request_id: FK to the related network access request
authenticated_at: When the user re-authenticated to start this session authenticated_at: When the user re-authenticated to start this session
expires_at: When the activation window ends expires_at: When the activation window ends
ended_at: When the session was explicitly ended (null if still active) ended_at: When the session was explicitly ended (null if still active)
@@ -38,9 +38,9 @@ class ActivationSession(BaseModel):
nullable=False, nullable=False,
index=True, index=True,
) )
device_network_membership_id = db.Column( network_access_request_id = db.Column(
db.String(36), db.String(36),
db.ForeignKey("device_network_memberships.id"), db.ForeignKey("network_access_requests.id"),
nullable=False, nullable=False,
index=True, index=True,
) )
@@ -75,14 +75,14 @@ class ActivationSession(BaseModel):
foreign_keys=[created_by], foreign_keys=[created_by],
backref="created_activation_sessions", backref="created_activation_sessions",
) )
membership = db.relationship( access_request = db.relationship(
"DeviceNetworkMembership", "NetworkAccessRequest",
back_populates="activation_sessions", back_populates="activation_sessions",
) )
def __repr__(self): def __repr__(self):
return ( return (
f"<ActivationSession membership={self.device_network_membership_id} " f"<ActivationSession request={self.network_access_request_id} "
f"expires={self.expires_at}>" f"expires={self.expires_at}>"
) )
+5 -5
View File
@@ -2,7 +2,7 @@
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.utils.constants import DeviceStatus from gatehouse_app.utils.constants import ApprovalState, DeviceStatus
class Device(BaseModel): class Device(BaseModel):
@@ -55,8 +55,8 @@ class Device(BaseModel):
# Relationships # Relationships
user = db.relationship("User", backref="devices") user = db.relationship("User", backref="devices")
organization = db.relationship("Organization", backref="devices") organization = db.relationship("Organization", backref="devices")
memberships = db.relationship( network_access_requests = db.relationship(
"DeviceNetworkMembership", "NetworkAccessRequest",
back_populates="device", back_populates="device",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
@@ -73,7 +73,7 @@ class Device(BaseModel):
data = super().to_dict(exclude=exclude) data = super().to_dict(exclude=exclude)
data["display_name"] = self.display_name data["display_name"] = self.display_name
data["active_membership_count"] = sum( data["active_membership_count"] = sum(
1 for m in self.memberships 1 for r in self.network_access_requests
if m.state == "active_authorized" and m.deleted_at is None if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
) )
return data return data
@@ -1,129 +0,0 @@
"""Device network membership — per-device, per-network workflow object."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import MembershipState
class DeviceNetworkMembership(BaseModel):
"""The main per-device, per-network workflow record.
This binds a specific Device to a specific PortalNetwork through a
UserNetworkApproval. It tracks both the internal portal state and the
observed ZeroTier membership state.
States:
pending_device_registration approval exists but no device registered yet
pending_request user has requested access but not yet approved
pending_manager_approval approval pending manager sign-off
approved_inactive approved but not currently active
joined_deauthorized device has joined ZT network but not authorized
active_authorized authorized and actively connected
activation_expired activation window ended (member still in ZT, deauth'd)
suspended temporarily suspended
revoked permanently revoked
rejected request was rejected
"""
__tablename__ = "device_network_memberships"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_id = db.Column(
db.String(36),
db.ForeignKey("devices.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
user_network_approval_id = db.Column(
db.String(36),
db.ForeignKey("user_network_approvals.id"),
nullable=True,
index=True,
)
state = db.Column(
db.Enum(MembershipState, name="membership_state", values_callable=lambda x: [e.value for e in x]),
default=MembershipState.PENDING_DEVICE_REGISTRATION,
nullable=False,
index=True,
)
join_seen = db.Column(db.Boolean, default=False, nullable=False)
currently_authorized = db.Column(db.Boolean, default=False, nullable=False)
approved_for_activation = db.Column(db.Boolean, default=True, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="network_memberships")
user = db.relationship("User", backref="network_memberships")
device = db.relationship("Device", back_populates="memberships")
portal_network = db.relationship(
"PortalNetwork",
back_populates="memberships",
)
approval = db.relationship(
"UserNetworkApproval",
back_populates="memberships",
)
activation_sessions = db.relationship(
"ActivationSession",
back_populates="membership",
cascade="all, delete-orphan",
)
zerotier_membership = db.relationship(
"ZeroTierMembership",
back_populates="device_network_membership",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"device_id",
"portal_network_id",
"deleted_at",
name="uix_device_network",
),
)
def __repr__(self):
return (
f"<DeviceNetworkMembership device={self.device_id} "
f"network={self.portal_network_id} state={self.state}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_session"] = (
self.active_session.to_dict() if self.active_session else None
)
return data
@@ -0,0 +1,149 @@
"""Network access request model — unified per-device, per-network access record."""
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ApprovalGrantType, ApprovalState
class NetworkAccessRequest(BaseModel):
"""A unified access record binding a user's device to a portal network.
Replaces the separate UserNetworkApproval and DeviceNetworkMembership
tables with a single per-device, per-network row. Each row tracks both
the business-level approval status and the device-level active/inactive
toggle.
Attributes:
organization_id: FK to the organization
user_id: FK to the requesting user
device_id: FK to the specific device
portal_network_id: FK to the portal network
granted_by_user_id: FK to the manager who approved (null for user-initiated)
grant_type: requested (user-initiated) or assigned (manager-initiated)
status: pending / approved / rejected / revoked / suspended
active: whether the device connection is currently live
justification: Business reason for the request
join_seen: Whether the device has been seen joining the ZeroTier network
"""
__tablename__ = "network_access_requests"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_id = db.Column(
db.String(36),
db.ForeignKey("devices.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
granted_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
)
grant_type = db.Column(
db.Enum(ApprovalGrantType, name="approval_grant_type", values_callable=lambda x: [e.value for e in x]),
default=ApprovalGrantType.REQUESTED,
nullable=False,
)
status = db.Column(
db.Enum(ApprovalState, name="approval_state", values_callable=lambda x: [e.value for e in x]),
default=ApprovalState.PENDING,
nullable=False,
index=True,
)
active = db.Column(
db.Boolean,
default=False,
nullable=False,
)
justification = db.Column(db.Text, nullable=True)
join_seen = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="network_access_requests")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="network_access_requests",
)
granted_by = db.relationship(
"User",
foreign_keys=[granted_by_user_id],
backref="granted_network_requests",
)
device = db.relationship(
"Device",
back_populates="network_access_requests",
)
portal_network = db.relationship(
"PortalNetwork",
backref="access_requests",
)
activation_sessions = db.relationship(
"ActivationSession",
back_populates="access_request",
cascade="all, delete-orphan",
)
zerotier_membership = db.relationship(
"ZeroTierMembership",
back_populates="access_request",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"user_id",
"device_id",
"portal_network_id",
"deleted_at",
name="uix_user_device_network",
),
)
def __repr__(self):
return (
f"<NetworkAccessRequest user={self.user_id} "
f"device={self.device_id} network={self.portal_network_id} "
f"status={self.status}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
session = self.active_session
data["active_session"] = session.to_dict() if session else None
data["device_name"] = self.device.display_name if self.device else None
data["device_nickname"] = self.device.device_nickname if self.device else None
return data
@@ -2,7 +2,7 @@
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.utils.constants import NetworkEnvironment, NetworkRequestMode from gatehouse_app.utils.constants import ApprovalState, NetworkEnvironment, NetworkRequestMode
class PortalNetwork(BaseModel): class PortalNetwork(BaseModel):
@@ -65,16 +65,6 @@ class PortalNetwork(BaseModel):
# Relationships # Relationships
organization = db.relationship("Organization", backref="portal_networks") organization = db.relationship("Organization", backref="portal_networks")
owner = db.relationship("User", backref="owned_networks") owner = db.relationship("User", backref="owned_networks")
approvals = db.relationship(
"UserNetworkApproval",
back_populates="portal_network",
cascade="all, delete-orphan",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="portal_network",
cascade="all, delete-orphan",
)
__table_args__ = ( __table_args__ = (
db.UniqueConstraint( db.UniqueConstraint(
@@ -91,10 +81,11 @@ class PortalNetwork(BaseModel):
exclude = exclude or [] exclude = exclude or []
data = super().to_dict(exclude=exclude) data = super().to_dict(exclude=exclude)
data["approved_user_count"] = sum( data["approved_user_count"] = sum(
1 for a in self.approvals if a.state == "approved" and a.deleted_at is None 1 for a in self.access_requests
if a.status == ApprovalState.APPROVED and a.deleted_at is None
) )
data["active_membership_count"] = sum( data["active_membership_count"] = sum(
1 for m in self.memberships 1 for r in self.access_requests
if m.state == "active_authorized" and m.deleted_at is None if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
) )
return data return data
@@ -1,106 +0,0 @@
"""User network approval model — durable manager approval for network access."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ApprovalGrantType, ApprovalState
class UserNetworkApproval(BaseModel):
"""A durable approval record binding a user to a portal network.
This is the business-level approval separate from any device and separate
from activation sessions. Manager approval survives across days and only
needs to be issued once unless explicitly revoked.
Attributes:
organization_id: FK to the organization
user_id: FK to the approved user
portal_network_id: FK to the portal network
granted_by_user_id: FK to the manager who approved (null for system-assigned)
grant_type: requested (user-initiated) or assigned (manager-initiated)
state: pending / approved / rejected / revoked / suspended
justification: Business reason for the approval
"""
__tablename__ = "user_network_approvals"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
granted_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
)
grant_type = db.Column(
db.Enum(ApprovalGrantType, name="approval_grant_type", values_callable=lambda x: [e.value for e in x]),
default=ApprovalGrantType.REQUESTED,
nullable=False,
)
state = db.Column(
db.Enum(ApprovalState, name="approval_state", values_callable=lambda x: [e.value for e in x]),
default=ApprovalState.PENDING,
nullable=False,
index=True,
)
justification = db.Column(db.Text, nullable=True)
# Relationships
organization = db.relationship("Organization", backref="network_approvals")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="network_approvals",
)
granted_by = db.relationship(
"User",
foreign_keys=[granted_by_user_id],
backref="granted_approvals",
)
portal_network = db.relationship(
"PortalNetwork",
back_populates="approvals",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="approval",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"user_id",
"portal_network_id",
"deleted_at",
name="uix_user_network_approval",
),
)
def __repr__(self):
return (
f"<UserNetworkApproval user={self.user_id} "
f"network={self.portal_network_id} state={self.state}>"
)
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_membership_count"] = sum(
1 for m in self.memberships if m.deleted_at is None
)
return data
@@ -15,7 +15,7 @@ class ZeroTierMembership(BaseModel):
Attributes: Attributes:
organization_id: FK to the organization organization_id: FK to the organization
device_network_membership_id: FK to the portal's membership record (nullable) network_access_request_id: FK to the portal's access request record (nullable)
zerotier_network_id: The 16-char hex ZeroTier network ID zerotier_network_id: The 16-char hex ZeroTier network ID
node_id: The 10-char hex ZeroTier node ID node_id: The 10-char hex ZeroTier node ID
member_seen: Whether the controller has ever seen this member member_seen: Whether the controller has ever seen this member
@@ -33,9 +33,9 @@ class ZeroTierMembership(BaseModel):
nullable=False, nullable=False,
index=True, index=True,
) )
device_network_membership_id = db.Column( network_access_request_id = db.Column(
db.String(36), db.String(36),
db.ForeignKey("device_network_memberships.id"), db.ForeignKey("network_access_requests.id"),
nullable=True, nullable=True,
index=True, index=True,
) )
@@ -57,8 +57,8 @@ class ZeroTierMembership(BaseModel):
# Relationships # Relationships
organization = db.relationship("Organization", backref="zerotier_memberships") organization = db.relationship("Organization", backref="zerotier_memberships")
device_network_membership = db.relationship( access_request = db.relationship(
"DeviceNetworkMembership", "NetworkAccessRequest",
back_populates="zerotier_membership", back_populates="zerotier_membership",
) )
+13 -8
View File
@@ -88,23 +88,28 @@ class AuditService:
) )
@staticmethod @staticmethod
def get_organization_activity(organization_id, limit=50): def get_organization_activity(organization_id, limit=50, user_id=None, action=None):
""" """
Get recent activity for an organization. Get recent activity for an organization.
Args: Args:
organization_id: Organization ID organization_id: Organization ID
limit: Maximum number of records to return limit: Maximum number of records to return
user_id: Optional user ID to filter by
action: Optional action type to filter by
Returns: Returns:
List of AuditLog instances List of AuditLog instances
""" """
return ( query = AuditLog.query.filter_by(organization_id=organization_id)
AuditLog.query.filter_by(organization_id=organization_id)
.order_by(AuditLog.created_at.desc()) if user_id:
.limit(limit) query = query.filter_by(user_id=user_id)
.all()
) if action:
query = query.filter_by(action=action)
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
# External Authentication Provider Audit Methods # External Authentication Provider Audit Methods
@@ -209,7 +214,7 @@ class AuditService:
): ):
"""Log external auth login event.""" """Log external auth login event."""
return AuditService.log_action( return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LOGIN, action=AuditAction.USER_LOGIN,
user_id=user_id, user_id=user_id,
organization_id=organization_id, organization_id=organization_id,
resource_type="session", resource_type="session",
+5 -12
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.user.user import User
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
from gatehouse_app.models.user.session import Session 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.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
@@ -165,6 +165,8 @@ class AuthService:
# Create session # Create session
session = Session( session = Session(
owner_type=SessionType.USER,
owner_id=user.id,
user_id=user.id, user_id=user.id,
token=token, token=token,
status=SessionStatus.ACTIVE, status=SessionStatus.ACTIVE,
@@ -176,15 +178,6 @@ class AuthService:
) )
session.save() session.save()
# Log session creation
AuditService.log_action(
action=AuditAction.SESSION_CREATE,
user_id=user.id,
resource_type="session",
resource_id=session.id,
description="User session created",
)
return session return session
@staticmethod @staticmethod
@@ -254,9 +247,9 @@ class AuthService:
if session: if session:
session.revoke(reason=reason) session.revoke(reason=reason)
# Log session revocation # Log session revocation (user logout)
AuditService.log_action( AuditService.log_action(
action=AuditAction.SESSION_REVOKE, action=AuditAction.USER_LOGOUT,
user_id=session.user_id, user_id=session.user_id,
resource_type="session", resource_type="session",
resource_id=session.id, resource_id=session.id,
+9 -8
View File
@@ -7,6 +7,7 @@ from gatehouse_app.extensions import db
from gatehouse_app.models import Device from gatehouse_app.models import Device
from gatehouse_app.models.user import User from gatehouse_app.models.user import User
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions import ( from gatehouse_app.exceptions import (
DeviceNotFoundError, DeviceNotFoundError,
DeviceAlreadyExistsError, DeviceAlreadyExistsError,
@@ -74,7 +75,7 @@ def register_device(
device.save() device.save()
AuditService.log_action( AuditService.log_action(
action="device.registered", action=AuditAction.DEVICE_REGISTERED,
user_id=user_id, user_id=user_id,
organization_id=organization_id, organization_id=organization_id,
resource_type="device", resource_type="device",
@@ -142,7 +143,7 @@ def update_device(
device.update(**kwargs) device.update(**kwargs)
AuditService.log_action( AuditService.log_action(
action="device.updated", action=AuditAction.DEVICE_UPDATED,
user_id=user_id, user_id=user_id,
organization_id=device.organization_id, organization_id=device.organization_id,
resource_type="device", resource_type="device",
@@ -167,20 +168,20 @@ def remove_device(device_id: str, user_id: str) -> None:
raise DeviceNotFoundError("Device not found.") raise DeviceNotFoundError("Device not found.")
# Soft-delete all memberships (deactivates active ones first) # Soft-delete all memberships (deactivates active ones first)
for membership in device.memberships: for request in device.network_access_requests:
if membership.deleted_at is None: if request.deleted_at is None:
from gatehouse_app.services.network_access_service import revoke_membership_soft from gatehouse_app.services.network_access_service import revoke_request_soft
revoke_membership_soft(membership.id, revoked_by_user_id=user_id) revoke_request_soft(request.id, revoker_user_id=user_id)
device.delete(soft=True) device.delete(soft=True)
AuditService.log_action( AuditService.log_action(
action="device.removed", action=AuditAction.DEVICE_REMOVED,
user_id=user_id, user_id=user_id,
organization_id=device.organization_id, organization_id=device.organization_id,
resource_type="device", resource_type="device",
resource_id=device.id, resource_id=device.id,
metadata={"node_id": device.node_id, "memberships_removed": len([m for m in device.memberships if m.deleted_at is None])}, metadata={"node_id": device.node_id, "memberships_removed": len([m for m in device.network_access_requests if m.deleted_at is None])},
description=f"Device {device.node_id} removed", description=f"Device {device.node_id} removed",
success=True, success=True,
) )
+64
View File
@@ -424,6 +424,70 @@ def build_mfa_suspension_html(
return get_base_html(content, "Account Access Restricted - MFA Enrollment Required", "Your account has been suspended due to missing MFA") return get_base_html(content, "Account Access Restricted - MFA Enrollment Required", "Your account has been suspended due to missing MFA")
def build_mfa_suspension_admin_html(
admin_name: str,
org_name: str,
suspended_user_name: str,
suspended_user_email: str,
mfa_methods: str,
members_link: str,
deadline_date: str = "",
days_overdue: int = 0,
) -> str:
"""Build MFA suspension notification email for org admins.
Args:
admin_name: Admin's name or email
org_name: Organization name
suspended_user_name: Suspended user's name
suspended_user_email: Suspended user's email
mfa_methods: Required MFA methods
members_link: Link to manage org members
deadline_date: The deadline that was missed
days_overdue: Days past the deadline
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {DANGER_COLOR}; font-size: 20px; font-weight: 600;">User Suspended - MFA Non-Compliance</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Dear <strong>{admin_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
A user in your organization <strong>{org_name}</strong> has been suspended due to MFA non-compliance.
</p>
{get_alert_box(f"A user account has been automatically suspended for failing to meet MFA requirements.", "warning", "⚙️")}
<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 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Suspended User Details:</h3>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
{get_detail_row("Name", suspended_user_name)}
{get_detail_row("Email", suspended_user_email)}
{get_detail_row("Organization", org_name)}
{get_detail_row("Required MFA", mfa_methods)}
{get_detail_row("Deadline", deadline_date) if deadline_date else ""}
{get_detail_row("Days Overdue", str(days_overdue)) if days_overdue else ""}
</table>
<h3 style="margin: 16px 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">What Happened:</h3>
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px; line-height: 1.6;">
This user did not configure the required multi-factor authentication method(s) within the
allowed grace period. Their account has been automatically suspended and they will only
be able to access a compliance enrollment screen until MFA is configured.
</p>
</td>
</tr>
</table>
{get_action_button(members_link, "Manage Organization Members", PRIMARY_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
You are receiving this notification because you are an administrator of <strong>{org_name}</strong>.
No action is required from you unless the user needs assistance setting up MFA.
</p>
'''
return get_base_html(content, f"User Suspended - MFA Non-Compliance in {org_name}", f"A user in {org_name} has been suspended for missing MFA deadline")
def build_org_invite_html( def build_org_invite_html(
inviter_name: str, inviter_name: str,
org_name: str, org_name: str,
+53 -13
View File
@@ -403,19 +403,19 @@ class MfaPolicyService:
) )
@staticmethod @staticmethod
def transition_to_suspended_if_past_due(now: Optional[datetime] = None) -> int: def transition_to_suspended_if_past_due(now: Optional[datetime] = None) -> List[Dict[str, Any]]:
"""Scheduled job to transition past-due users to suspended status. """Scheduled job to transition past-due users to suspended status.
Args: Args:
now: Current time, defaults to now now: Current time, defaults to now
Returns: Returns:
Number of users transitioned to suspended List of dicts with suspended record details (user, compliance, org_policy)
""" """
if now is None: if now is None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
suspended_count = 0 suspended_records = []
# Find all compliance records that are past due # Find all compliance records that are past due
past_due_records = MfaPolicyCompliance.query.filter( past_due_records = MfaPolicyCompliance.query.filter(
@@ -437,21 +437,65 @@ class MfaPolicyService:
# Update user status # Update user status
user = User.query.get(record.user_id) user = User.query.get(record.user_id)
if user and user.status != UserStatus.COMPLIANCE_SUSPENDED: if not user:
continue
if user.status != UserStatus.COMPLIANCE_SUSPENDED:
user.status = UserStatus.COMPLIANCE_SUSPENDED user.status = UserStatus.COMPLIANCE_SUSPENDED
db.session.commit() db.session.commit()
# Audit log # Get org policy for extended details
org_policy = OrganizationSecurityPolicy.query.filter_by(
organization_id=record.organization_id, deleted_at=None
).first()
days_overdue = (now - deadline).days if deadline else 0
# Audit log for user (with extended details)
AuditService.log_action( AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED, action=AuditAction.MFA_POLICY_USER_SUSPENDED,
user_id=record.user_id, user_id=record.user_id,
organization_id=record.organization_id, organization_id=record.organization_id,
resource_type="user",
resource_id=record.user_id,
description=f"User suspended due to MFA compliance deadline passed", description=f"User suspended due to MFA compliance deadline passed",
metadata={
"deadline_at": record.deadline_at.isoformat() if record.deadline_at else None,
"suspended_at": now.isoformat(),
"days_overdue": days_overdue,
"user_email": user.email,
"policy_mode": org_policy.mfa_policy_mode.value if org_policy else None,
"grace_period_days": org_policy.mfa_grace_period_days if org_policy else None,
"policy_version": org_policy.policy_version if org_policy else None,
"reason": "MFA compliance deadline passed without required enrollment",
},
) )
suspended_count += 1 # Audit log for org (org-scoped entry for admin visibility)
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
user_id=None,
organization_id=record.organization_id,
resource_type="user",
resource_id=record.user_id,
description=f"Organization member {user.email} suspended due to MFA non-compliance",
metadata={
"suspended_user_id": record.user_id,
"suspended_user_email": user.email,
"deadline_at": record.deadline_at.isoformat() if record.deadline_at else None,
"suspended_at": now.isoformat(),
"days_overdue": days_overdue,
"policy_mode": org_policy.mfa_policy_mode.value if org_policy else None,
},
)
return suspended_count suspended_records.append({
"user": user,
"compliance": record,
"org_policy": org_policy,
})
return suspended_records
@staticmethod @staticmethod
def create_org_policy( def create_org_policy(
@@ -871,11 +915,9 @@ class MfaPolicyService:
org_ids = [org.organization_id for org in suspended_orgs] org_ids = [org.organization_id for org in suspended_orgs]
AuditService.log_action( AuditService.log_action(
action=AuditAction.USER_LOGIN, action=AuditAction.LOGIN_BLOCKED_COMPLIANCE,
user_id=user.id, user_id=user.id,
organization_id=org_ids[0] if org_ids else None, organization_id=org_ids[0] if org_ids else None,
ip_address=ip_address,
user_agent=user_agent,
description=f"Login attempt while compliance suspended. Suspended orgs: {org_ids}", description=f"Login attempt while compliance suspended. Suspended orgs: {org_ids}",
success=False, success=False,
error_message="MFA compliance required", error_message="MFA compliance required",
@@ -900,10 +942,8 @@ class MfaPolicyService:
user_agent: Client user agent user_agent: Client user agent
""" """
AuditService.log_action( AuditService.log_action(
action=AuditAction.USER_LOGIN, # Reusing USER_LOGIN for audit action=AuditAction.MFA_COMPLIANCE_BYPASS_ATTEMPT,
user_id=user.id, user_id=user.id,
ip_address=ip_address,
user_agent=user_agent,
resource_type="endpoint", resource_type="endpoint",
resource_id=endpoint, resource_id=endpoint,
description=f"Policy bypass attempt - compliance-only session accessed {endpoint}", description=f"Policy bypass attempt - compliance-only session accessed {endpoint}",
File diff suppressed because it is too large Load Diff
+97 -2
View File
@@ -28,6 +28,7 @@ from gatehouse_app.services.email_provider import EmailMessage, EmailProviderFac
from gatehouse_app.services.email_templates import ( from gatehouse_app.services.email_templates import (
build_mfa_deadline_reminder_html, build_mfa_deadline_reminder_html,
build_mfa_suspension_html, build_mfa_suspension_html,
build_mfa_suspension_admin_html,
) )
from gatehouse_app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
@@ -123,7 +124,7 @@ class NotificationService:
f"({days_until_deadline} days remaining)" f"({days_until_deadline} days remaining)"
) )
AuditService.log_action( AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_COMPLIANT, action=AuditAction.MFA_NOTIFICATION_SENT,
user_id=user.id, user_id=user.id,
organization_id=compliance.organization_id, organization_id=compliance.organization_id,
description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}", description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}",
@@ -196,7 +197,7 @@ class NotificationService:
) )
logger.info(f"Sent MFA suspension notification to {user.email}") logger.info(f"Sent MFA suspension notification to {user.email}")
AuditService.log_action( AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED, action=AuditAction.MFA_SUSPENSION_NOTIFICATION_SENT,
user_id=user.id, user_id=user.id,
organization_id=compliance.organization_id, organization_id=compliance.organization_id,
description="MFA compliance suspension notification sent", description="MFA compliance suspension notification sent",
@@ -209,6 +210,100 @@ class NotificationService:
) )
return False return False
@staticmethod
def send_mfa_suspended_admin_notification(
admin_user: User,
suspended_user: User,
compliance: MfaPolicyCompliance,
org_policy: OrganizationSecurityPolicy,
) -> bool:
"""Notify org admin that a user has been suspended for MFA non-compliance.
Sends an email to organization admins/owners when a member of their
organization has been automatically suspended for failing to meet MFA
compliance requirements.
Args:
admin_user: Admin/owner to notify
suspended_user: The user who was suspended
compliance: Suspended user's compliance record
org_policy: Organization's MFA policy
Returns:
True if notification was sent successfully, False otherwise
"""
try:
org_name = compliance.organization_id
from gatehouse_app.models.organization.organization import Organization
org = Organization.query.get(compliance.organization_id)
if org:
org_name = org.name
from gatehouse_app.utils.constants import MfaPolicyMode
mfa_methods = "Multi-factor authentication"
mode = org_policy.mfa_policy_mode
if mode == MfaPolicyMode.REQUIRE_TOTP:
mfa_methods = "Authenticator app (TOTP)"
elif mode == MfaPolicyMode.REQUIRE_WEBAUTHN:
mfa_methods = "Passkey (WebAuthn)"
elif mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN:
mfa_methods = "Authenticator app (TOTP) OR Passkey (WebAuthn)"
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
members_link = f"{app_url}/organizations/{compliance.organization_id}/members"
deadline_str = compliance.deadline_at.strftime('%Y-%m-%d %H:%M UTC') if compliance.deadline_at else ''
days_overdue = 0
if compliance.deadline_at:
deadline = compliance.deadline_at
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
from datetime import timezone as dt_tz
now = datetime.now(timezone.utc)
days_overdue = max(0, (now - deadline).days)
subject = f"User Suspended - MFA Non-Compliance in {org_name}"
html_body = build_mfa_suspension_admin_html(
admin_name=admin_user.full_name or admin_user.email,
org_name=org_name,
suspended_user_name=suspended_user.full_name or suspended_user.email,
suspended_user_email=suspended_user.email,
mfa_methods=mfa_methods,
members_link=members_link,
deadline_date=deadline_str,
days_overdue=days_overdue,
)
NotificationService._send_email_async(
to_address=admin_user.email,
subject=subject,
body=f"A user ({suspended_user.email}) in {org_name} has been suspended for MFA non-compliance. Manage members: {members_link}",
html_body=html_body,
)
logger.info(
f"Sent MFA suspension admin notification to {admin_user.email} "
f"regarding suspended user {suspended_user.email}"
)
AuditService.log_action(
action=AuditAction.MFA_SUSPENSION_ADMIN_NOTIFICATION_SENT,
user_id=suspended_user.id,
organization_id=compliance.organization_id,
description=f"Admin {admin_user.email} notified about MFA suspension of user {suspended_user.email}",
metadata={
"admin_user_id": admin_user.id,
"admin_email": admin_user.email,
"suspended_user_email": suspended_user.email,
"org_name": org_name,
},
)
return True
except Exception as e:
logger.exception(
f"Error sending MFA suspension admin notification to {admin_user.email}: {e}"
)
return False
@staticmethod @staticmethod
def _build_deadline_reminder_body( def _build_deadline_reminder_body(
user: User, user: User,
+1 -1
View File
@@ -246,7 +246,7 @@ def handle_login_callback(
auth_method.save() auth_method.save()
AuditService.log_action( AuditService.log_action(
action="user.register", action=AuditAction.USER_REGISTER,
user_id=user.id, user_id=user.id,
organization_id=state_record.organization_id, organization_id=state_record.organization_id,
resource_type="user", resource_type="user",
@@ -142,7 +142,7 @@ def handle_register_callback(
state_record.mark_used() state_record.mark_used()
AuditService.log_action( AuditService.log_action(
action="user.register", action=AuditAction.USER_REGISTER,
user_id=user.id, user_id=user.id,
organization_id=state_record.organization_id, organization_id=state_record.organization_id,
resource_type="user", resource_type="user",
@@ -353,7 +353,7 @@ class OrganizationService:
resource_type="organization_member", resource_type="organization_member",
resource_id=member.id, resource_id=member.id,
metadata={"added_user_id": user_id, "role": role.value}, metadata={"added_user_id": user_id, "role": role.value},
description=f"Member added to organization with role: {role.value}", description=f"Member {user_id} added to organization with role: {role.value}",
) )
return member return member
@@ -398,7 +398,7 @@ class OrganizationService:
resource_type="organization_member", resource_type="organization_member",
resource_id=member.id, resource_id=member.id,
metadata={"removed_user_id": user_id}, metadata={"removed_user_id": user_id},
description="Member removed from organization", description=f"Member {user_id} removed from organization",
) )
@staticmethod @staticmethod
@@ -438,7 +438,7 @@ class OrganizationService:
"old_role": old_role.value, "old_role": old_role.value,
"new_role": new_role.value, "new_role": new_role.value,
}, },
description=f"Member role changed from {old_role.value} to {new_role.value}", description=f"Member {user_id} role changed from {old_role.value} to {new_role.value}",
) )
return member return member
@@ -6,10 +6,11 @@ import re
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.models import PortalNetwork from gatehouse_app.models import PortalNetwork
from gatehouse_app.models.organization import Organization from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user import User from gatehouse_app.models.user import User
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services import zerotier_api_service as zt from gatehouse_app.services import zerotier_api_service as zt
from gatehouse_app.utils.constants import NetworkRequestMode, NetworkEnvironment from gatehouse_app.utils.constants import NetworkRequestMode, NetworkEnvironment, AuditAction
from gatehouse_app.exceptions import ( from gatehouse_app.exceptions import (
NetworkNotFoundError, NetworkNotFoundError,
InvalidNetworkIdError, InvalidNetworkIdError,
@@ -110,7 +111,7 @@ def create_network(
deleted.save() deleted.save()
AuditService.log_action( AuditService.log_action(
action="zt.network.restored", action=AuditAction.ZT_NETWORK_RESTORED,
user_id=owner_user_id, user_id=owner_user_id,
organization_id=organization_id, organization_id=organization_id,
resource_type="portal_network", resource_type="portal_network",
@@ -157,7 +158,7 @@ def create_network(
) )
AuditService.log_action( AuditService.log_action(
action="zt.network.created", action=AuditAction.ZT_NETWORK_CREATED,
user_id=owner_user_id, user_id=owner_user_id,
organization_id=organization_id, organization_id=organization_id,
resource_type="portal_network", resource_type="portal_network",
@@ -178,14 +179,29 @@ def create_network(
def list_networks( def list_networks(
organization_id: str, organization_id: str,
include_inactive: bool = False, include_inactive: bool = False,
user_id: str | None = None,
) -> list[PortalNetwork]: ) -> list[PortalNetwork]:
"""List portal networks for an organization.""" """List portal networks for an organization.
Invite-only networks are hidden from non-admin users.
"""
q = PortalNetwork.query.filter( q = PortalNetwork.query.filter(
PortalNetwork.organization_id == organization_id, PortalNetwork.organization_id == organization_id,
PortalNetwork.deleted_at.is_(None), PortalNetwork.deleted_at.is_(None),
) )
if not include_inactive: if not include_inactive:
q = q.filter(PortalNetwork.is_active.is_(True)) q = q.filter(PortalNetwork.is_active.is_(True))
if user_id is not None:
membership = OrganizationMember.query.filter(
OrganizationMember.organization_id == organization_id,
OrganizationMember.user_id == user_id,
OrganizationMember.deleted_at.is_(None),
).first()
is_admin = membership.is_admin() if membership else False
if not is_admin:
q = q.filter(PortalNetwork.request_mode != NetworkRequestMode.INVITE_ONLY)
return q.all() return q.all()
@@ -246,7 +262,7 @@ def update_network(
network.update(**kwargs) network.update(**kwargs)
AuditService.log_action( AuditService.log_action(
action="zt.network.updated", action=AuditAction.ZT_NETWORK_UPDATED,
user_id=user_id, user_id=user_id,
organization_id=network.organization_id, organization_id=network.organization_id,
resource_type="portal_network", resource_type="portal_network",
@@ -262,51 +278,37 @@ def update_network(
def delete_network(network_id: str, user_id: str) -> None: def delete_network(network_id: str, user_id: str) -> None:
"""Soft-delete a portal network and deactivate/clean up all related records.""" """Soft-delete a portal network and deactivate/clean up all related records."""
from datetime import datetime, timezone from datetime import datetime, timezone
from gatehouse_app.models import UserNetworkApproval
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
network = get_network(network_id) network = get_network(network_id)
# Deauthorize all active memberships in ZeroTier # Deauthorize all active memberships in ZeroTier
for membership in network.memberships: for request in network.access_requests:
if membership.deleted_at is None and membership.state.value == "active_authorized": if request.deleted_at is None and request.active:
from gatehouse_app.services.network_access_service import deactivate_membership from gatehouse_app.services.network_access_service import deactivate_request
deactivate_membership(membership.id, reason="network_deleted") deactivate_request(request.id, reason="network_deleted")
network.delete(soft=True) network.delete(soft=True)
# Cascade soft-delete all active approvals and memberships for this network. # Cascade soft-delete all active access requests for this network.
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
db.session.execute( db.session.execute(
db.text( db.text(
"UPDATE user_network_approvals AS a " "UPDATE network_access_requests AS a "
"SET deleted_at = :now + (s.rn * interval '1 microsecond') " "SET deleted_at = :now + (s.rn * interval '1 microsecond') "
"FROM (" "FROM ("
" SELECT id, row_number() OVER () AS rn " " SELECT id, row_number() OVER () AS rn "
" FROM user_network_approvals " " FROM network_access_requests "
" WHERE portal_network_id = :network_id AND deleted_at IS NULL" " WHERE portal_network_id = :network_id AND deleted_at IS NULL"
") s " ") s "
"WHERE a.id = s.id" "WHERE a.id = s.id"
), ),
{"now": now, "network_id": network_id}, {"now": now, "network_id": network_id},
) )
db.session.execute(
db.text(
"UPDATE device_network_memberships AS m "
"SET deleted_at = :now + (s.rn * interval '1 microsecond') "
"FROM ("
" SELECT id, row_number() OVER () AS rn "
" FROM device_network_memberships "
" WHERE portal_network_id = :network_id AND deleted_at IS NULL"
") s "
"WHERE m.id = s.id"
),
{"now": now, "network_id": network_id},
)
db.session.commit() db.session.commit()
AuditService.log_action( AuditService.log_action(
action="zt.network.deleted", action=AuditAction.ZT_NETWORK_DELETED,
user_id=user_id, user_id=user_id,
organization_id=network.organization_id, organization_id=network.organization_id,
resource_type="portal_network", resource_type="portal_network",
@@ -318,22 +320,46 @@ def delete_network(network_id: str, user_id: str) -> None:
def get_network_members(network_id: str) -> list: def get_network_members(network_id: str) -> list:
"""Return all DeviceNetworkMemberships for a network with user and device info.""" """Return all approved NetworkAccessRequests for a network (active or inactive)."""
from gatehouse_app.models import DeviceNetworkMembership from gatehouse_app.models import NetworkAccessRequest
from gatehouse_app.utils.constants import ApprovalState
return DeviceNetworkMembership.query.filter( members = (
DeviceNetworkMembership.portal_network_id == network_id, NetworkAccessRequest.query
DeviceNetworkMembership.deleted_at.is_(None), .options(
).all() db.joinedload(NetworkAccessRequest.user),
db.joinedload(NetworkAccessRequest.device),
)
.filter(
NetworkAccessRequest.portal_network_id == network_id,
NetworkAccessRequest.status == ApprovalState.APPROVED,
NetworkAccessRequest.deleted_at.is_(None),
)
.order_by(NetworkAccessRequest.created_at.desc())
.all()
)
result = []
for m in members:
d = m.to_dict()
user = m.user
device = m.device
d["user_email"] = user.email if user else None
d["user_name"] = user.full_name if user else None
d["device_name"] = device.display_name if device else None
d["device_node_id"] = device.node_id if device else None
result.append(d)
return result
def get_network_pending_requests(network_id: str) -> list: def get_network_pending_requests(network_id: str) -> list:
"""Return pending UserNetworkApprovals for a network.""" """Return pending NetworkAccessRequests for a network."""
from gatehouse_app.models import UserNetworkApproval from gatehouse_app.models import NetworkAccessRequest
from gatehouse_app.utils.constants import ApprovalState from gatehouse_app.utils.constants import ApprovalState
return UserNetworkApproval.query.filter( return NetworkAccessRequest.query.filter(
UserNetworkApproval.portal_network_id == network_id, NetworkAccessRequest.portal_network_id == network_id,
UserNetworkApproval.state == ApprovalState.PENDING, NetworkAccessRequest.status == ApprovalState.PENDING,
UserNetworkApproval.deleted_at.is_(None), NetworkAccessRequest.deleted_at.is_(None),
).all() ).all()
+64 -11
View File
@@ -1,7 +1,7 @@
"""Session service.""" """Session service."""
from datetime import datetime, timezone from datetime import datetime, timezone
from gatehouse_app.models.user.session import Session 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: class SessionService:
@@ -28,18 +28,22 @@ class SessionService:
).first() ).first()
@staticmethod @staticmethod
def get_user_sessions(user_id, active_only=True): def get_owner_sessions(owner_type, owner_id, active_only=True):
""" """Get all sessions for an owner (user or superadmin).
Get all sessions for a user.
Args: Args:
user_id: User ID owner_type: SessionType.USER or SessionType.SUPERADMIN
owner_id: Owner ID
active_only: If True, only return active sessions active_only: If True, only return active sessions
Returns: Returns:
List of Session instances 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: if active_only:
query = query.filter_by(status=SessionStatus.ACTIVE).filter( query = query.filter_by(status=SessionStatus.ACTIVE).filter(
@@ -49,18 +53,67 @@ class SessionService:
return query.all() return query.all()
@staticmethod @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: Args:
user_id: User ID user_id: User ID
reason: Reason for revocation 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: @staticmethod
session.revoke(reason=reason) 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 @staticmethod
def cleanup_expired_sessions(): def cleanup_expired_sessions():
@@ -6,7 +6,9 @@ from typing import Optional
from flask import request, current_app from flask import request, current_app
from gatehouse_app.extensions import db, bcrypt 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 from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -70,15 +72,17 @@ class SuperadminAuthService:
duration_seconds: Session duration in seconds (default 8 hours) duration_seconds: Session duration in seconds (default 8 hours)
Returns: Returns:
SuperadminSession instance Session instance
""" """
# Generate secure token # Generate secure token
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
# Create session # Create session using unified model
session = SuperadminSession( session = Session(
superadmin_id=superadmin_id, owner_type=SessionType.SUPERADMIN,
owner_id=superadmin_id,
token=token, token=token,
status="active",
expires_at=datetime.now(timezone.utc) + timedelta(seconds=duration_seconds), expires_at=datetime.now(timezone.utc) + timedelta(seconds=duration_seconds),
last_activity_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc),
ip_address=request.remote_addr, ip_address=request.remote_addr,
@@ -97,7 +101,9 @@ class SuperadminAuthService:
session_id: Session ID to revoke session_id: Session ID to revoke
reason: Optional revocation reason 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: if session:
session.revoke(reason=reason) session.revoke(reason=reason)
logger.info(f"[SuperadminAuth] Session {session_id} revoked: {reason or 'No 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) except_token: Optional token to keep (current session)
reason: Optional revocation reason 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: if except_token:
query = query.filter(SuperadminSession.token != except_token) query = query.filter(Session.token != except_token)
sessions = query.all() sessions = query.all()
for session in sessions: for session in sessions:
+7
View File
@@ -105,6 +105,7 @@ class UserService:
- Session (all active sessions killed) - Session (all active sessions killed)
- OIDCAuthCode (pending auth codes invalidated) - OIDCAuthCode (pending auth codes invalidated)
- OIDCRefreshToken (refresh tokens invalidated) - OIDCRefreshToken (refresh tokens invalidated)
- OAuthState (OAuth flow states invalidated)
- OIDCSession (OIDC sessions killed) - OIDCSession (OIDC sessions killed)
- OIDCTokenMetadata (token metadata hidden) - OIDCTokenMetadata (token metadata hidden)
@@ -120,6 +121,7 @@ class UserService:
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from gatehouse_app.extensions import db as _db from gatehouse_app.extensions import db as _db
from gatehouse_app.models.auth.authentication_method import OAuthState
if soft: if soft:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -169,6 +171,11 @@ class UserService:
pass pass
cert.deleted_at = now cert.deleted_at = now
# --- OAuth states -----------------------------------------------
OAuthState.query.filter_by(user_id=user.id).filter(
OAuthState.deleted_at == None
).update({"deleted_at": now}, synchronize_session=False)
# --- Sessions --------------------------------------------------- # --- Sessions ---------------------------------------------------
for session in user.sessions: for session in user.sessions:
if session.deleted_at is None: if session.deleted_at is None:
@@ -7,17 +7,16 @@ from datetime import datetime, timezone
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.models import ( from gatehouse_app.models import (
Device, Device,
DeviceNetworkMembership, NetworkAccessRequest,
ActivationSession, ActivationSession,
ZeroTierMembership, ZeroTierMembership,
PortalNetwork, PortalNetwork,
UserNetworkApproval,
) )
from gatehouse_app.services import zerotier_api_service as zt from gatehouse_app.services import zerotier_api_service as zt
from gatehouse_app.utils.constants import ( from gatehouse_app.utils.constants import (
ActivationEndReason, ActivationEndReason,
MembershipState,
ApprovalState, ApprovalState,
AuditAction,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,7 +44,7 @@ def reconcile_expired_activations() -> int:
except Exception as exc: except Exception as exc:
logger.error( logger.error(
f"[Reconciliation] Failed to expire session {session.id} " f"[Reconciliation] Failed to expire session {session.id} "
f"(user={session.user_id} membership={session.device_network_membership_id}): {exc}", f"(user={session.user_id} request={session.network_access_request_id}): {exc}",
exc_info=True, exc_info=True,
) )
@@ -104,9 +103,9 @@ def reconcile_network(portal_network_id: str) -> dict:
# Get our portal memberships for this network # Get our portal memberships for this network
our_memberships = { our_memberships = {
m.device.node_id: m m.device.node_id: m
for m in DeviceNetworkMembership.query.filter( for m in NetworkAccessRequest.query.filter(
DeviceNetworkMembership.portal_network_id == portal_network_id, NetworkAccessRequest.portal_network_id == portal_network_id,
DeviceNetworkMembership.deleted_at.is_(None), NetworkAccessRequest.deleted_at.is_(None),
).all() ).all()
if m.device and m.device.deleted_at is None if m.device and m.device.deleted_at is None
} }
@@ -124,7 +123,7 @@ def reconcile_network(portal_network_id: str) -> dict:
# Member not seen in ZT yet — could be freshly joined or never connected # Member not seen in ZT yet — could be freshly joined or never connected
logger.debug( logger.debug(
f"[Reconciliation] {network_label}: node {node_id} " f"[Reconciliation] {network_label}: node {node_id} "
f"(device={device.display_name!r}, state={membership.state}) not yet seen in ZT controller." f"(device={device.display_name!r}, active={membership.active}) not yet seen in ZT controller."
) )
continue continue
@@ -134,11 +133,11 @@ def reconcile_network(portal_network_id: str) -> dict:
_sync_zt_membership(membership, zt_member) _sync_zt_membership(membership, zt_member)
# Sync authorization state # Sync authorization state
if membership.state == MembershipState.ACTIVE_AUTHORIZED: if membership.active:
if not zt_member.is_authorized: if not zt_member.is_authorized:
# Portal says active but ZT disagrees — drift, re-authorize # Portal says active but ZT disagrees — drift, re-authorize
logger.warning( logger.warning(
f"[Reconciliation] {network_label}: DRIFT detected — portal=ACTIVE_AUTHORIZED " f"[Reconciliation] {network_label}: DRIFT detected — portal=active "
f"but ZT says unauthorized for node {node_id} (device={device.display_name!r}). Re-authorizing." f"but ZT says unauthorized for node {node_id} (device={device.display_name!r}). Re-authorizing."
) )
try: try:
@@ -154,13 +153,13 @@ def reconcile_network(portal_network_id: str) -> dict:
) )
else: else:
logger.debug( logger.debug(
f"[Reconciliation] {network_label}: node {node_id} — portal=ACTIVE_AUTHORIZED, ZT=authorized. OK." f"[Reconciliation] {network_label}: node {node_id} — portal=active, ZT=authorized. OK."
) )
else: else:
if zt_member.is_authorized: if zt_member.is_authorized:
# ZT says authorized but portal doesn't — could be manual override in ZT console # ZT says authorized but portal doesn't — could be manual override in ZT console
logger.warning( logger.warning(
f"[Reconciliation] {network_label}: DRIFT detected — portal state={membership.state} " f"[Reconciliation] {network_label}: DRIFT detected — portal=inactive "
f"but ZT says authorized for node {node_id} (device={device.display_name!r}). Deauthorizing." f"but ZT says authorized for node {node_id} (device={device.display_name!r}). Deauthorizing."
) )
try: try:
@@ -177,7 +176,7 @@ def reconcile_network(portal_network_id: str) -> dict:
else: else:
logger.debug( logger.debug(
f"[Reconciliation] {network_label}: node {node_id}" f"[Reconciliation] {network_label}: node {node_id}"
f"portal={membership.state}, ZT=unauthorized. OK." f"portal=inactive, ZT=unauthorized. OK."
) )
# Unknown ZT members not in our portal — log only, do not touch # Unknown ZT members not in our portal — log only, do not touch
@@ -261,11 +260,11 @@ def reconcile_deleted_memberships() -> dict:
"""Find soft-deleted memberships and hard-delete them after ZeroTier cleanup. """Find soft-deleted memberships and hard-delete them after ZeroTier cleanup.
Only processes memberships whose ZeroTier members are already de-authorized Only processes memberships whose ZeroTier members are already de-authorized
(the de-authorize step happened in revoke_membership_soft). This function (the de-authorize step happened in revoke_request_soft). This function
removes the member from ZeroTier entirely and then hard-deletes the DB record. removes the member from ZeroTier entirely and then hard-deletes the DB record.
""" """
deleted = DeviceNetworkMembership.query.filter( deleted = NetworkAccessRequest.query.filter(
DeviceNetworkMembership.deleted_at.isnot(None), NetworkAccessRequest.deleted_at.isnot(None),
).all() ).all()
if not deleted: if not deleted:
@@ -328,7 +327,7 @@ def reconcile_deleted_memberships() -> dict:
return results return results
def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None: def _sync_zt_membership(membership: NetworkAccessRequest, zt_member) -> None:
"""Update the ZeroTierMembership cache record from a ZT API response.""" """Update the ZeroTierMembership cache record from a ZT API response."""
device = membership.device device = membership.device
network = membership.portal_network network = membership.portal_network
@@ -347,7 +346,7 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
) )
zt_membership = ZeroTierMembership( zt_membership = ZeroTierMembership(
organization_id=membership.organization_id, organization_id=membership.organization_id,
device_network_membership_id=membership.id, network_access_request_id=membership.id,
zerotier_network_id=network.zerotier_network_id, zerotier_network_id=network.zerotier_network_id,
node_id=device.node_id, node_id=device.node_id,
) )
@@ -377,10 +376,10 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
logger.info( logger.info(
f"[Reconciliation] First join seen for node {device.node_id} " f"[Reconciliation] First join seen for node {device.node_id} "
f"(device={device.display_name!r}, membership={membership.id}). " f"(device={device.display_name!r}, membership={membership.id}). "
f"State: {membership.state}{MembershipState.JOINED_DEAUTHORIZED}" f"Setting join_seen=True, active=False"
) )
membership.join_seen = True membership.join_seen = True
membership.state = MembershipState.JOINED_DEAUTHORIZED membership.active = False
membership.save() membership.save()
else: else:
logger.debug( logger.debug(
@@ -397,23 +396,22 @@ def _expire_session(session: ActivationSession) -> None:
logger.info( logger.info(
f"[Reconciliation] Expiring activation session {session.id} " f"[Reconciliation] Expiring activation session {session.id} "
f"(user={session.user_id}, membership={session.device_network_membership_id}, " f"(user={session.user_id}, request={session.network_access_request_id}, "
f"expired_at={session.expires_at.isoformat()})." f"expired_at={session.expires_at.isoformat()})."
) )
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id) request = NetworkAccessRequest.query.get(session.network_access_request_id)
if not membership: if not request:
logger.warning( logger.warning(
f"[Reconciliation] Session {session.id}: membership " f"[Reconciliation] Session {session.id}: request "
f"{session.device_network_membership_id} not found — skipping ZT deauth." f"{session.network_access_request_id} not found — skipping ZT deauth."
) )
else: else:
membership.state = MembershipState.ACTIVATION_EXPIRED request.active = False
membership.currently_authorized = False request.save()
membership.save()
device = Device.query.get(membership.device_id) device = Device.query.get(request.device_id)
network = PortalNetwork.query.get(membership.portal_network_id) network = PortalNetwork.query.get(request.portal_network_id)
if device and network: if device and network:
network_label = f"{network.name} ({network.zerotier_network_id})" network_label = f"{network.name} ({network.zerotier_network_id})"
try: try:
@@ -449,18 +447,18 @@ def _expire_session(session: ActivationSession) -> None:
else: else:
logger.warning( logger.warning(
f"[Reconciliation] Session {session.id}: missing " f"[Reconciliation] Session {session.id}: missing "
f"{'device' if not device else 'network'} for membership " f"{'device' if not device else 'network'} for request "
f"{membership.id} — ZT deauth skipped." f"{request.id} — ZT deauth skipped."
) )
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
AuditService.log_action( AuditService.log_action(
action="zt.activation.expired", action=AuditAction.ZT_ACTIVATION_EXPIRED,
user_id=session.user_id, user_id=session.user_id,
organization_id=session.organization_id, organization_id=session.organization_id,
resource_type="activation_session", resource_type="activation_session",
resource_id=session.id, resource_id=session.id,
metadata={"membership_id": session.device_network_membership_id}, metadata={"request_id": session.network_access_request_id},
description="Activation session expired", description="Activation session expired",
success=True, success=True,
) )
+64 -15
View File
@@ -52,6 +52,13 @@ class SessionStatus(str, Enum):
REVOKED = "revoked" REVOKED = "revoked"
class SessionType(str, Enum):
"""Session owner type discriminator."""
USER = "user"
SUPERADMIN = "superadmin"
class AuditAction(str, Enum): class AuditAction(str, Enum):
"""Audit log action types.""" """Audit log action types."""
@@ -64,9 +71,17 @@ class AuditAction(str, Enum):
USER_HARD_DELETE = "user.hard_delete" USER_HARD_DELETE = "user.hard_delete"
USER_SUSPEND = "user.suspend" USER_SUSPEND = "user.suspend"
USER_UNSUSPEND = "user.unsuspend" USER_UNSUSPEND = "user.unsuspend"
USER_RESTORE = "user.restore"
PASSWORD_CHANGE = "user.password_change" PASSWORD_CHANGE = "user.password_change"
PASSWORD_RESET = "user.password_reset" PASSWORD_RESET = "user.password_reset"
# Login/security events
LOGIN_BLOCKED_COMPLIANCE = "login.blocked.compliance"
MFA_COMPLIANCE_BYPASS_ATTEMPT = "mfa.compliance.bypass_attempt"
MFA_NOTIFICATION_SENT = "mfa.notification.sent"
MFA_SUSPENSION_NOTIFICATION_SENT = "mfa.suspension_notification.sent"
MFA_SUSPENSION_ADMIN_NOTIFICATION_SENT = "mfa.suspension_admin_notification.sent"
# Organization actions # Organization actions
ORG_CREATE = "org.create" ORG_CREATE = "org.create"
ORG_UPDATE = "org.update" ORG_UPDATE = "org.update"
@@ -154,6 +169,55 @@ class AuditAction(str, Enum):
DEPARTMENT_DELETED = "department.deleted" DEPARTMENT_DELETED = "department.deleted"
DEPARTMENT_MEMBER_ADDED = "department.member.added" DEPARTMENT_MEMBER_ADDED = "department.member.added"
DEPARTMENT_MEMBER_REMOVED = "department.member.removed" 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"
# ZeroTier network actions
ZT_APPROVAL_REOPENED = "zt.approval.reopened"
ZT_APPROVAL_REQUESTED = "zt.approval.requested"
ZT_APPROVAL_GRANTED = "zt.approval.granted"
ZT_APPROVAL_REJECTED = "zt.approval.rejected"
ZT_APPROVAL_REVOKED = "zt.approval.revoked"
ZT_MEMBERSHIP_ACTIVATED = "zt.membership.activated"
ZT_MEMBERSHIP_DEACTIVATED = "zt.membership.deactivated"
ZT_MEMBERSHIP_CREATED = "zt.membership.created"
ZT_MEMBER_AUTHORIZED = "zt.member.authorized"
ZT_MEMBER_DEAUTHORIZED = "zt.member.deauthorized"
ZT_REQUEST_REVOKED = "zt.request.revoked"
ZT_KILL_SWITCH_ACTIVATED = "zt.kill_switch.activated"
ZT_NETWORK_KILL_SWITCH = "zt.network_kill_switch.activated"
ZT_ACTIVATION_EXPIRED = "zt.activation.expired"
ZT_SESSION_ENDED = "zt.session.ended"
ZT_NETWORK_CREATED = "zt.network.created"
ZT_NETWORK_UPDATED = "zt.network.updated"
ZT_NETWORK_DELETED = "zt.network.deleted"
ZT_NETWORK_RESTORED = "zt.network.restored"
ZT_CONFIG_UPDATED = "org.zerotier_config.updated"
ZT_CONFIG_DELETED = "org.zerotier_config.deleted"
# Device actions
DEVICE_REGISTERED = "device.registered"
DEVICE_UPDATED = "device.updated"
DEVICE_REMOVED = "device.removed"
class OIDCGrantType(str, Enum): class OIDCGrantType(str, Enum):
@@ -253,21 +317,6 @@ class ApprovalState(str, Enum):
SUSPENDED = "suspended" SUSPENDED = "suspended"
class MembershipState(str, Enum):
"""State of a device network membership record."""
PENDING_DEVICE_REGISTRATION = "pending_device_registration"
PENDING_REQUEST = "pending_request"
PENDING_MANAGER_APPROVAL = "pending_manager_approval"
APPROVED_INACTIVE = "approved_inactive"
JOINED_DEAUTHORIZED = "joined_deauthorized"
ACTIVE_AUTHORIZED = "active_authorized"
ACTIVATION_EXPIRED = "activation_expired"
SUSPENDED = "suspended"
REVOKED = "revoked"
REJECTED = "rejected"
class ActivationEndReason(str, Enum): class ActivationEndReason(str, Enum):
"""Why an activation session ended.""" """Why an activation session ended."""
+94
View File
@@ -0,0 +1,94 @@
"""Validation helpers for request data."""
from urllib.parse import urlparse
# Special sentinel values allowed in allowed_cors_origins
_CORS_SENTINELS = {"+", "*"}
def validate_cors_origins(origins):
"""Validate a list of CORS origin values.
Accepts:
None - means "use global CORS config" (pass-through)
["+"] - derive origins from the client's redirect_uris
["*"] - allow any origin
["https://host"] - explicit allow-list of well-formed origins
Each non-sentinel entry must be a well-formed origin:
scheme (http or https) + host + optional port, with NO path,
query string, or fragment.
Returns:
(validated_value, None) on success, or
(None, error_message) on failure.
"""
if origins is None:
return None, None
if not isinstance(origins, list):
return None, "allowed_cors_origins must be a list or null"
validated = []
for i, entry in enumerate(origins):
if not isinstance(entry, str):
return None, f"allowed_cors_origins[{i}]: expected a string, got {type(entry).__name__}"
entry = entry.strip()
if not entry:
return None, f"allowed_cors_origins[{i}]: empty string is not allowed"
# Sentinel values are accepted as-is
if entry in _CORS_SENTINELS:
validated.append(entry)
continue
# Parse and validate as origin
error = _validate_single_origin(entry, i)
if error:
return None, error
validated.append(entry)
return validated, None
def _validate_single_origin(origin, index):
"""Validate that a string is a well-formed browser origin.
A valid origin is: scheme://host[:port] with no path, query, or fragment.
Only http and https schemes are accepted.
Returns an error message string on failure, or None on success.
"""
try:
parsed = urlparse(origin)
except Exception:
return f"allowed_cors_origins[{index}]: '{origin}' is not a valid URL"
if parsed.scheme not in ("http", "https"):
return (
f"allowed_cors_origins[{index}]: '{origin}' has an invalid scheme "
f"'{parsed.scheme}'; only 'http' and 'https' are allowed"
)
if not parsed.hostname:
return f"allowed_cors_origins[{index}]: '{origin}' is missing a hostname"
# Origins must not have a path (other than empty or "/"), query, or fragment
if parsed.path and parsed.path != "/":
return (
f"allowed_cors_origins[{index}]: '{origin}' must not contain a path "
f"(got '{parsed.path}'). Specify only scheme://host[:port]"
)
if parsed.query:
return (
f"allowed_cors_origins[{index}]: '{origin}' must not contain a query string"
)
if parsed.fragment:
return (
f"allowed_cors_origins[{index}]: '{origin}' must not contain a fragment"
)
return None
+4
View File
@@ -106,6 +106,10 @@ def run_zerotier_reconciliation():
print("Job Results:") print("Job Results:")
print(f" Expired activations: {result['expired_activations']}") print(f" Expired activations: {result['expired_activations']}")
print(f" Networks processed: {result['networks_processed']}") print(f" Networks processed: {result['networks_processed']}")
print(f" Authorized: {result['authorized']}")
print(f" Deauthorized: {result['deauthorized']}")
print(f" Purged memberships: {result['deleted_memberships']}")
print(f" Purge errors: {result['delete_errors']}")
print(f" Errors: {result['errors']}") print(f" Errors: {result['errors']}")
print() print()

Some files were not shown because too many files have changed in this diff Show More