29 Commits

Author SHA1 Message Date
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
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
76 changed files with 6876 additions and 1676 deletions
+6 -1
View File
@@ -141,4 +141,9 @@ flask_session/
# Opencode files and folders
.opencode/
.swarm/
SWARM_PLAN.*
SWARM_PLAN.*
# local backups / dumps / sessions
*.sql
*.dump
session-*.md
backups/
+60 -5
View File
@@ -369,7 +369,12 @@ def request_certificate(org_id=None):
json_result = response.json().get('data', response.json())
with open(CERT_FILE_PATH, 'w') as f:
f.write(json_result['certificate'])
os.chmod(CERT_FILE_PATH, 0o600)
try:
os.chmod(CERT_FILE_PATH, 0o600)
except OSError:
pass
logger.info(f"Certificate signed successfully, located at {CERT_FILE_PATH}")
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")
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__":
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")
@@ -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("--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-orgs", action='store_true', default=False, help="List all organizations you are a member of")
parser.add_argument("--org-id", metavar='ORG_ID', help="Specify organization ID for certificate signing (required if member of multiple orgs)")
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("--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()
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):
parser.error("At least one of --check-cert, --request-cert, --add-key, --list-keys, --remove-key, --list-orgs, or --clear-cache must be provided.")
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, --clear-cache, --list-orgs, or --install-known-hosts must be provided.")
# Retrieve SSH key from environment variables if not provided via CLI
@@ -677,6 +728,10 @@ if __name__ == "__main__":
add_ssh_key(ssh_key_file)
exit(0)
if args.install_known_hosts:
request_token()
install_known_hosts()
exit(0)
if args.request_cert:
request_token()
+1 -1
View File
@@ -77,7 +77,7 @@ class BaseConfig:
SESSION_REDIS = None # Will be set at app initialization
# 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_DEFAULT = "100/hour"
+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
+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.
+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}")
except Exception as 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."""
from flask import Blueprint
from gatehouse_app.utils.response import api_response
from gatehouse_app.extensions import limiter
# Create main API blueprint
api_bp = Blueprint("api", __name__)
@api_bp.route("/health", methods=["GET"])
@limiter.exempt
def health_check():
"""Health check endpoint."""
return api_response(
+1 -1
View File
@@ -5,7 +5,7 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__)
# 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
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.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.services.audit_service import AuditService
@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)
# 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 = {
"user": user.to_dict(),
"token": user_session.token,
+23
View File
@@ -1,4 +1,5 @@
"""TOTP authentication endpoints."""
import logging
from flask import request, session, g, current_app
from marshmallow import ValidationError
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.mfa_policy_service import MfaPolicyService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.exceptions.validation_exceptions import ConflictError
@@ -78,6 +80,19 @@ def verify_totp():
is_compliance_only = policy_result.create_compliance_only_session
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("webauthn_pending_user_id", None)
@@ -112,6 +127,14 @@ def verify_totp():
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
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)
+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.webauthn_service import WebAuthnService
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.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)
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}")
response_data = {
@@ -161,6 +175,14 @@ def complete_webauthn_login():
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
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)
except Exception as e:
logger.exception(f"WebAuthn login complete unexpected error: {e}")
-4
View File
@@ -18,15 +18,12 @@ class DepartmentCreateSchema(Schema):
"""Schema for creating a department."""
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
can_sudo = fields.Bool(allow_none=True, load_default=False)
class DepartmentUpdateSchema(Schema):
"""Schema for updating a department."""
name = fields.Str(validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
can_sudo = fields.Bool(allow_none=True)
class AddDepartmentMemberSchema(Schema):
@@ -124,7 +121,6 @@ def create_department(org_id):
organization_id=org_id,
name=data["name"],
description=data.get("description"),
can_sudo=data.get("can_sudo", False),
)
db.session.add(dept)
db.session.commit()
+8
View File
@@ -29,6 +29,7 @@ from gatehouse_app.exceptions.auth_exceptions import (
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__)
@@ -816,6 +817,11 @@ def oidc_register():
except Exception:
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_secret = f"secret_{secrets.token_urlsafe(24)}"
client_secret_hash = flask_bcrypt.generate_password_hash(client_secret).decode("utf-8")
@@ -842,6 +848,7 @@ def oidc_register():
grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]),
response_types=data.get("response_types", ["code"]),
scopes=data.get("scope", "openid profile email roles").split(),
allowed_cors_origins=cors_origins,
is_active=True,
is_confidential=True,
require_pkce=True,
@@ -871,6 +878,7 @@ def oidc_register():
"client_secret_expires_at": 0,
"client_name": client_name,
"redirect_uris": redirect_uris,
"allowed_cors_origins": client.allowed_cors_origins,
"token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_basic"),
"grant_types": client.grant_types,
"response_types": client.response_types,
@@ -1,4 +1,4 @@
"""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"]
@@ -43,10 +43,13 @@ def get_organization_audit_logs(org_id):
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200)
action_filter = request.args.get("action")
user_id_filter = request.args.get("user_id")
query = AuditLog.query.filter_by(organization_id=org_id)
if 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())
total = query.count()
+5 -9
View File
@@ -203,13 +203,12 @@ def delete_org_ca(org_id, ca_id):
ca.is_active = False
ca.delete(soft=True)
AuditLog.log(
AuditService.log_action(
action=AuditAction.CA_DELETED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="CA",
resource_id=ca_id,
organization_id=org_id,
ip_address=request.remote_addr,
description=f"CA '{ca_name}' ({ca_type}) deleted",
)
return api_response(data={"ca_id": ca_id}, message="CA deleted successfully")
@@ -227,8 +226,6 @@ def rotate_org_ca(org_id, ca_id):
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
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
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
@@ -265,14 +262,13 @@ def rotate_org_ca(org_id, ca_id):
ca.key_type = KeyType(new_key_type)
db.session.commit()
AuditLog.log(
AuditService.log_action(
action=AuditAction.CA_KEY_ROTATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="CA",
resource_id=ca_id,
organization_id=org_id,
ip_address=request.remote_addr,
description=(f"CA '{ca.name}' key rotated. Old fingerprint: {old_fingerprint}, New fingerprint: {new_fingerprint}. Reason: {reason}"),
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.")
@@ -7,6 +7,7 @@ from gatehouse_app.utils.decorators import login_required, require_admin, full_a
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"])
@@ -63,6 +64,11 @@ def create_org_client(org_id):
if not redirect_uris:
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_secret = _secrets.token_urlsafe(32)
@@ -75,6 +81,7 @@ def create_org_client(org_id):
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scopes=["openid", "profile", "email"],
allowed_cors_origins=cors_origins,
is_active=True,
is_confidential=True,
)
@@ -99,6 +106,7 @@ def create_org_client(org_id):
"client_secret": client_secret,
"redirect_uris": client.redirect_uris,
"scopes": client.scopes,
"allowed_cors_origins": client.allowed_cors_origins,
"created_at": client.created_at.isoformat() + "Z",
}
},
@@ -135,6 +143,12 @@ 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")
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()
AuditService.log_action(
@@ -155,6 +169,7 @@ def update_org_client(org_id, client_id):
"redirect_uris": client.redirect_uris,
"scopes": client.scopes,
"grant_types": client.grant_types,
"allowed_cors_origins": client.allowed_cors_origins,
"is_active": client.is_active,
"created_at": client.created_at.isoformat() + "Z",
}
+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.services.mfa_policy_service import MfaPolicyService
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, AuditAction
from gatehouse_app.utils.constants import MfaPolicyMode, MfaRequirementOverride, MfaComplianceStatus
class UpdateOrgPolicySchema(Schema):
@@ -291,16 +290,6 @@ def update_user_security_policy(org_id, 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(
data={
"user_security_policy": {
+21 -3
View File
@@ -2,7 +2,7 @@
from flask import request, g
from gatehouse_app.api.v1.ssh._helpers import ssh_bp
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.response import api_response
@@ -78,7 +78,14 @@ def add_ca_permission(ca_id):
db.session.add(perm)
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["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):
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()
if not perm:
return api_response(success=False, message="Permission not found", status=404, error_type="NOT_FOUND")
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")
+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.exceptions import SSHKeyNotFoundError, SSHCertificateError
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.utils.decorators import login_required
from gatehouse_app.utils.response import api_response
@@ -68,10 +68,11 @@ def sign_certificate():
expiry_hours = data.get('expiry_hours')
requested_org_id = data.get('organization_id')
AuditLog.log(
AuditService.log_action(
action=AuditAction.SSH_CERT_REQUESTED,
user_id=user_id, resource_type='SSHCertificate', ip_address=request.remote_addr,
description=(f'{user.email} requested a certificate' + (f' for principals: {", ".join(requested_principals)}' if requested_principals else '')),
user_id=user_id,
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
@@ -209,10 +210,24 @@ def sign_certificate():
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)
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")
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")
cert_record = _persist_certificate(
@@ -221,12 +236,14 @@ def sign_certificate():
cert_type_str=cert_type, cert_identity=cert_identity,
)
AuditLog.log(
action=AuditAction.SSH_CERT_ISSUED, user_id=user_id,
resource_type='SSHCertificate', resource_id=cert_record.id if cert_record else key_id,
ip_address=request.remote_addr,
description=f'Certificate serial={response.serial} issued for {user.email}; principals: {", ".join(principals)}',
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},
AuditService.log_action(
action=AuditAction.SSH_CERT_ISSUED,
user_id=user_id,
organization_id=str(target_org.id),
resource_type="SSHCertificate",
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:
@@ -340,7 +357,15 @@ def sign_host_certificate():
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)
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")
cert_record = _persist_certificate(
@@ -349,12 +374,14 @@ def sign_host_certificate():
cert_type_str="host", cert_identity=cert_identity,
)
AuditLog.log(
action=AuditAction.SSH_CERT_ISSUED, user_id=user_id,
resource_type="SSHCertificate", resource_id=cert_record.id if cert_record else None,
ip_address=request.remote_addr,
AuditService.log_action(
action=AuditAction.SSH_CERT_ISSUED,
user_id=user_id,
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}",
extra_data={"serial": response.serial, "principals": principals, "ca_id": str(host_ca.id), "cert_type": "host"},
)
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')
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
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.exceptions import SSHKeyError, SSHKeyNotFoundError, ValidationError, SSHKeyAlreadyExistsError
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.response import api_response
@@ -34,7 +34,13 @@ def add_ssh_key():
try:
ssh_key, is_new = ssh_key_service.add_ssh_key(user_id=user_id, public_key=public_key, description=description)
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)
else:
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:
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
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)
except SSHKeyNotFoundError:
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')
try:
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)
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')
else:
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",
)
+232
View File
@@ -514,3 +514,235 @@ def remove_user_from_org(user_id, org_id):
status=500,
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.ssh_ca.ssh_key import SSHKey
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.utils.constants import AuditAction, OrganizationRole
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.user_service import UserService
caller = g.current_user
data = request.get_json() or {}
@@ -372,20 +372,10 @@ def admin_delete_user(user_id):
target_email = target.email
target_id_str = str(target.id)
now = datetime.now(timezone.utc)
try:
# Soft delete the user — set deleted_at timestamp.
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()
UserService.delete_user(target, soft=True)
except Exception as exc:
_db.session.rollback()
_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")
@@ -443,7 +433,7 @@ def admin_restore_user(user_id):
_db.session.commit()
AuditService.log_action(
action=AuditAction.USER_UNSUSPEND, # closest existing action
action=AuditAction.USER_RESTORE,
user_id=caller.id,
organization_id=_get_admin_access(caller, target).organization_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}")
@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"])
@login_required
@full_access_required
+281 -66
View File
@@ -1,8 +1,11 @@
"""ZeroTier network governance API endpoints."""
from datetime import datetime, timezone
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from gatehouse_app.api.v1 import api_v1_bp
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 zerotier_api_service as zt
from gatehouse_app.services import zerotier_reconciliation_service
from gatehouse_app.services.user_service import UserService
from gatehouse_app.models import (
PortalNetwork,
Device,
DeviceNetworkMembership,
UserNetworkApproval,
ActivationSession,
NetworkAccessRequest,
)
from gatehouse_app.models.organization import Organization
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 (
ValidationError as AppValidationError,
ZeroTierAPIError,
@@ -30,7 +33,6 @@ from gatehouse_app.exceptions import (
DeviceNotFoundError,
DeviceAlreadyExistsError,
ApprovalNotFoundError,
MembershipNotFoundError,
)
@@ -115,6 +117,10 @@ class KillSwitchSchema(Schema):
network_ids = fields.List(fields.Str(), allow_none=True)
class NetworkKillSwitchSchema(Schema):
reason = fields.Str(allow_none=True, validate=validate.Length(max=500))
# ── Networks ──────────────────────────────────────────────────────────────────
@@ -128,7 +134,11 @@ def list_networks(org_id):
return err
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(
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)
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",
)
@@ -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"])
@login_required
@full_access_required
@@ -373,11 +424,8 @@ def register_device(org_id):
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(
data={"device": device.to_dict(), "memberships_created": len(memberships)},
data={"device": device.to_dict()},
message="Device registered successfully",
status=201,
)
@@ -486,7 +534,7 @@ def list_my_approvals(org_id):
if 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(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
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)
@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
@require_admin
@full_access_required
def revoke_approval(org_id, approval_id):
def revoke_approval(org_id, request_id):
"""Revoke an approved access record (admin only)."""
org, err = _org_check(org_id)
if err:
return err
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")
except ApprovalNotFoundError as e:
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")
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(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully",
@@ -626,10 +674,10 @@ def list_memberships(org_id):
if err:
return err
memberships = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.user_id == g.current_user.id,
DeviceNetworkMembership.organization_id == org_id,
DeviceNetworkMembership.deleted_at.is_(None),
memberships = NetworkAccessRequest.query.filter(
NetworkAccessRequest.user_id == g.current_user.id,
NetworkAccessRequest.organization_id == org_id,
NetworkAccessRequest.deleted_at.is_(None),
).all()
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)
try:
session = network_access_service.activate_device_membership(
membership_id=membership_id,
session = network_access_service.activate_request(
request_id=membership_id,
user_id=g.current_user.id,
lifetime_minutes=data.get("lifetime_minutes"),
admin_override=is_admin,
)
membership = DeviceNetworkMembership.query.get(membership_id)
return api_response(data={"session": session.to_dict(), "membership": membership.to_dict()}, message="Membership activated successfully")
except MembershipNotFoundError as e:
return api_response(data={"session": session.to_dict()}, message="Request activated successfully")
except ApprovalNotFoundError as e:
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)
@@ -681,22 +728,22 @@ def deactivate_membership(org_id, membership_id):
# Verify ownership for non-admins
if not _is_org_admin(org_id, g.current_user.id):
membership_check = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.id == membership_id,
DeviceNetworkMembership.user_id == g.current_user.id,
DeviceNetworkMembership.deleted_at.is_(None),
membership_check = NetworkAccessRequest.query.filter(
NetworkAccessRequest.id == membership_id,
NetworkAccessRequest.user_id == g.current_user.id,
NetworkAccessRequest.deleted_at.is_(None),
).first()
if not membership_check:
return api_response(success=False, message="Membership not found", status=404, error_type="NOT_FOUND")
try:
membership = network_access_service.deactivate_membership(
membership_id=membership_id,
req = network_access_service.deactivate_request(
request_id=membership_id,
reason="manual_revoke",
deactivated_by_user_id=g.current_user.id,
)
return api_response(data={"membership": membership.to_dict()}, message="Membership deactivated successfully")
except MembershipNotFoundError as e:
return api_response(data={"request": req.to_dict()}, message="Request deactivated successfully")
except ApprovalNotFoundError as e:
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
@full_access_required
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)
if err:
return err
is_admin = _is_org_admin(org_id, g.current_user.id)
try:
membership = network_access_service.join_network_for_device(
user_id=g.current_user.id,
organization_id=org_id,
device_id=device_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)
except AppValidationError as e:
@@ -759,15 +810,71 @@ def delete_membership(org_id, membership_id):
return err
try:
network_access_service.revoke_membership_soft(
membership_id=membership_id,
revoked_by_user_id=g.current_user.id,
network_access_service.revoke_request_soft(
request_id=membership_id,
revoker_user_id=g.current_user.id,
)
return api_response(message="Membership removed successfully")
except MembershipNotFoundError as e:
return api_response(message="Request revoked successfully")
except ApprovalNotFoundError as e:
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 ─────────────────────────────────────────────────────────────────
@@ -780,15 +887,27 @@ def list_sessions(org_id):
if err:
return err
sessions = ActivationSession.query.filter(
ActivationSession.user_id == g.current_user.id,
ActivationSession.organization_id == org_id,
ActivationSession.ended_at.is_(None),
ActivationSession.deleted_at.is_(None),
).all()
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.organization_id == org_id,
ActivationSession.ended_at.is_(None),
ActivationSession.deleted_at.is_(None),
)
.all()
)
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",
)
@@ -816,18 +935,80 @@ def end_session(org_id, session_id):
from gatehouse_app.services.network_access_service import _end_session
from gatehouse_app.utils.constants import ActivationEndReason
from datetime import datetime, timezone
_end_session(session, ActivationEndReason.LOGOUT)
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
if membership:
from gatehouse_app.services.network_access_service import deactivate_membership
deactivate_membership(membership.id, reason="logout")
if session.network_access_request_id:
network_access_service.deactivate_request(session.network_access_request_id, reason="logout")
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 ───────────────────────────────────────────────────────────────
@@ -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)
try:
event = network_access_service.kill_switch(
target_user_id=data["target_user_id"],
triggered_by_user_id=g.current_user.id,
organization_id=org_id,
scope=data.get("scope", "organization"),
reason=data.get("reason"),
from gatehouse_app.utils.constants import KillSwitchScope
scope = data.get("scope", "organization")
scope_enum = KillSwitchScope(scope) if scope in KillSwitchScope._value2member_map_ else KillSwitchScope.ORGANIZATION
count = network_access_service.kill_switch(
user_id=data["target_user_id"],
org_id=org_id,
scope=scope_enum,
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:
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 ───────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/admin/memberships", methods=["GET"])
@@ -873,10 +1079,10 @@ def admin_list_memberships(org_id):
if 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(
data={"memberships": memberships, "count": len(memberships)},
message="All memberships retrieved successfully",
data={"requests": requests, "count": len(requests)},
message="All requests retrieved successfully",
)
@@ -885,16 +1091,25 @@ def admin_list_memberships(org_id):
@require_admin
@full_access_required
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)
if err:
return err
try:
network_access_service.hard_delete_membership(membership_id)
return api_response(message="Membership permanently deleted")
except MembershipNotFoundError as e:
network_access_service.admin_force_delete_request(
membership_id,
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)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
# ── ZeroTier Controller ───────────────────────────────────────────────────────
@@ -1115,7 +1330,7 @@ def set_zerotier_config(org_id):
from gatehouse_app.services.audit_service import AuditService
AuditService.log_action(
action="org.zerotier_config.updated",
action=AuditAction.ZT_CONFIG_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="organization",
@@ -1167,7 +1382,7 @@ def delete_zerotier_config(org_id):
from gatehouse_app.services.audit_service import AuditService
AuditService.log_action(
action="org.zerotier_config.deleted",
action=AuditAction.ZT_CONFIG_DELETED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="organization",
+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.organization_security_policy import OrganizationSecurityPolicy
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.notification_service import NotificationService
from gatehouse_app.utils.constants import MfaComplianceStatus
from gatehouse_app.utils.constants import MfaComplianceStatus, OrganizationRole
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:
1. Transitions users from PAST_DUE to SUSPENDED status
2. Identifies users approaching deadline (within notify_days_before)
3. Sends deadline reminder notifications
4. Updates notification tracking metadata
2. Sends suspension notification to suspended users
3. Sends suspension notification to org admins
4. Identifies users approaching deadline (within notify_days_before)
5. Sends deadline reminder notifications
6. Updates notification tracking metadata
Args:
now: Current time, defaults to now (UTC)
@@ -45,7 +48,9 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
Returns:
Dictionary with job execution statistics:
- 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
"""
if now is None:
@@ -55,6 +60,8 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
stats = {
"suspended_count": 0,
"user_notified_count": 0,
"admin_notified_count": 0,
"notified_count": 0,
"processed_count": 0,
"errors": [],
@@ -62,16 +69,67 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
try:
# Step 1: Transition past-due users to suspended
suspended_count = MfaPolicyService.transition_to_suspended_if_past_due(now)
stats["suspended_count"] = suspended_count
logger.info(f"Transitioned {suspended_count} users to suspended status")
suspended_records = MfaPolicyService.transition_to_suspended_if_past_due(now)
stats["suspended_count"] = len(suspended_records)
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)
stats["notified_count"] = notified_count
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)
stats["processed_count"] = processed_count
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(
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
@@ -44,6 +44,10 @@ def run_reconciliation() -> dict:
results = {
"expired_activations": 0,
"networks_processed": 0,
"authorized": 0,
"deauthorized": 0,
"deleted_memberships": 0,
"delete_errors": 0,
"errors": 0,
}
@@ -56,6 +60,10 @@ def run_reconciliation() -> dict:
try:
summary = zerotier_reconciliation_service.reconcile_all()
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)
except Exception as 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"expired={results['expired_activations']} "
f"networks={results['networks_processed']} "
f"authorized={results['authorized']} "
f"deauthorized={results['deauthorized']} "
f"purged={results['deleted_memberships']} "
f"errors={results['errors']}"
)
+18 -14
View File
@@ -17,9 +17,8 @@ models.ssh_ca — CA, KeyType, CertType, CaType, CAPermission,
CertificateAuditLog
models.security OrganizationSecurityPolicy, UserSecurityPolicy,
MfaPolicyCompliance
models.zerotier PortalNetwork, Device, UserNetworkApproval,
DeviceNetworkMembership, ActivationSession,
ZeroTierMembership, KillSwitchEvent
models.zerotier PortalNetwork, Device, NetworkAccessRequest,
ActivationSession, ZeroTierMembership, KillSwitchEvent
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
@@ -103,15 +102,9 @@ from gatehouse_app.models.security.mfa_policy_compliance import (
MfaPolicyCompliance,
)
# ── ZeroTier / Portal Network ─────────────────────────────────────────────────
from gatehouse_app.models.zerotier import ( # noqa: F401
PortalNetwork,
Device,
UserNetworkApproval,
DeviceNetworkMembership,
ActivationSession,
ZeroTierMembership,
KillSwitchEvent,
# ── External Auth ─────────────────────────────────────────────────────────────
from gatehouse_app.services.external_auth.models import ( # noqa: F401
ExternalProviderConfig,
)
# ── Superadmin ─────────────────────────────────────────────────────────────────
@@ -127,6 +120,16 @@ from gatehouse_app.models.security.mfa_policy_compliance import ( # noqa: F401
MfaPolicyCompliance,
)
# ── ZeroTier ──────────────────────────────────────────────────────────────
from gatehouse_app.models.zerotier import ( # noqa: F401
PortalNetwork,
Device,
NetworkAccessRequest,
ActivationSession,
ZeroTierMembership,
KillSwitchEvent,
)
__all__ = [
# Base
"BaseModel",
@@ -174,11 +177,12 @@ __all__ = [
"OrganizationSecurityPolicy",
"UserSecurityPolicy",
"MfaPolicyCompliance",
# External Auth
"ExternalProviderConfig",
# ZeroTier
"PortalNetwork",
"Device",
"UserNetworkApproval",
"DeviceNetworkMembership",
"NetworkAccessRequest",
"ActivationSession",
"ZeroTierMembership",
"KillSwitchEvent",
-17
View File
@@ -1,7 +1,6 @@
"""Audit log model."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import AuditAction
class AuditLog(BaseModel):
@@ -42,19 +41,3 @@ class AuditLog(BaseModel):
def __repr__(self):
"""String representation of AuditLog."""
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),
primary_key=True,
default=lambda: str(uuid.uuid4()),
unique=True,
nullable=False,
)
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -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.org_invite_token import OrgInviteToken
from gatehouse_app.models.organization.organization_api_key import OrganizationApiKey
__all__ = [
"Organization",
@@ -25,5 +24,4 @@ __all__ = [
"Principal",
"PrincipalMembership",
"OrgInviteToken",
"OrganizationApiKey",
]
@@ -27,7 +27,6 @@ class Department(BaseModel):
)
name = db.Column(db.String(255), nullable=False, index=True)
description = db.Column(db.Text, nullable=True)
can_sudo = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
organization = db.relationship("Organization", back_populates="departments")
@@ -47,9 +47,6 @@ class Organization(BaseModel):
cas = db.relationship(
"CA", back_populates="organization", cascade="all, delete-orphan"
)
api_keys = db.relationship(
"OrganizationApiKey", back_populates="organization", cascade="all, delete-orphan"
)
def __repr__(self):
"""String representation of Organization."""
@@ -71,11 +68,18 @@ class Organization(BaseModel):
def is_member(self, user_id: str) -> bool:
"""Check if a user is a member of the organization."""
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user.user import User
return (
OrganizationMember.query.filter_by(
user_id=user_id, organization_id=self.id, deleted_at=None
).first()
db.session.query(OrganizationMember)
.join(User, OrganizationMember.user_id == User.id)
.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
)
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]
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 -4
View File
@@ -2,8 +2,7 @@
PortalNetwork manager-created network bound to a ZT network ID
Device user-registered ZeroTier node endpoint
UserNetworkApproval durable manager approval for network access
DeviceNetworkMembership per-device per-network workflow record
NetworkAccessRequest unified per-device, per-network access record
ActivationSession temporary activation window
ZeroTierMembership observed controller-side member state
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.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.network_access_request import NetworkAccessRequest # 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
@@ -16,7 +16,7 @@ class ActivationSession(BaseModel):
Attributes:
organization_id: FK to the organization
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
expires_at: When the activation window ends
ended_at: When the session was explicitly ended (null if still active)
@@ -38,9 +38,9 @@ class ActivationSession(BaseModel):
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
network_access_request_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
db.ForeignKey("network_access_requests.id"),
nullable=False,
index=True,
)
@@ -75,14 +75,14 @@ class ActivationSession(BaseModel):
foreign_keys=[created_by],
backref="created_activation_sessions",
)
membership = db.relationship(
"DeviceNetworkMembership",
access_request = db.relationship(
"NetworkAccessRequest",
back_populates="activation_sessions",
)
def __repr__(self):
return (
f"<ActivationSession membership={self.device_network_membership_id} "
f"<ActivationSession request={self.network_access_request_id} "
f"expires={self.expires_at}>"
)
+5 -5
View File
@@ -2,7 +2,7 @@
from gatehouse_app.extensions import db
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):
@@ -55,8 +55,8 @@ class Device(BaseModel):
# Relationships
user = db.relationship("User", backref="devices")
organization = db.relationship("Organization", backref="devices")
memberships = db.relationship(
"DeviceNetworkMembership",
network_access_requests = db.relationship(
"NetworkAccessRequest",
back_populates="device",
cascade="all, delete-orphan",
)
@@ -73,7 +73,7 @@ class Device(BaseModel):
data = super().to_dict(exclude=exclude)
data["display_name"] = self.display_name
data["active_membership_count"] = sum(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
1 for r in self.network_access_requests
if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
)
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.models.base import BaseModel
from gatehouse_app.utils.constants import NetworkEnvironment, NetworkRequestMode
from gatehouse_app.utils.constants import ApprovalState, NetworkEnvironment, NetworkRequestMode
class PortalNetwork(BaseModel):
@@ -65,16 +65,6 @@ class PortalNetwork(BaseModel):
# Relationships
organization = db.relationship("Organization", backref="portal_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__ = (
db.UniqueConstraint(
@@ -91,10 +81,11 @@ class PortalNetwork(BaseModel):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
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(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
1 for r in self.access_requests
if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
)
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:
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
node_id: The 10-char hex ZeroTier node ID
member_seen: Whether the controller has ever seen this member
@@ -33,9 +33,9 @@ class ZeroTierMembership(BaseModel):
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
network_access_request_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
db.ForeignKey("network_access_requests.id"),
nullable=True,
index=True,
)
@@ -57,8 +57,8 @@ class ZeroTierMembership(BaseModel):
# Relationships
organization = db.relationship("Organization", backref="zerotier_memberships")
device_network_membership = db.relationship(
"DeviceNetworkMembership",
access_request = db.relationship(
"NetworkAccessRequest",
back_populates="zerotier_membership",
)
+13 -8
View File
@@ -88,23 +88,28 @@ class AuditService:
)
@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.
Args:
organization_id: Organization ID
limit: Maximum number of records to return
user_id: Optional user ID to filter by
action: Optional action type to filter by
Returns:
List of AuditLog instances
"""
return (
AuditLog.query.filter_by(organization_id=organization_id)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.all()
)
query = AuditLog.query.filter_by(organization_id=organization_id)
if user_id:
query = query.filter_by(user_id=user_id)
if action:
query = query.filter_by(action=action)
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
# External Authentication Provider Audit Methods
@@ -209,7 +214,7 @@ class AuditService:
):
"""Log external auth login event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LOGIN,
action=AuditAction.USER_LOGIN,
user_id=user_id,
organization_id=organization_id,
resource_type="session",
+2 -11
View File
@@ -178,15 +178,6 @@ class AuthService:
)
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
@staticmethod
@@ -256,9 +247,9 @@ class AuthService:
if session:
session.revoke(reason=reason)
# Log session revocation
# Log session revocation (user logout)
AuditService.log_action(
action=AuditAction.SESSION_REVOKE,
action=AuditAction.USER_LOGOUT,
user_id=session.user_id,
resource_type="session",
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.user import User
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions import (
DeviceNotFoundError,
DeviceAlreadyExistsError,
@@ -74,7 +75,7 @@ def register_device(
device.save()
AuditService.log_action(
action="device.registered",
action=AuditAction.DEVICE_REGISTERED,
user_id=user_id,
organization_id=organization_id,
resource_type="device",
@@ -142,7 +143,7 @@ def update_device(
device.update(**kwargs)
AuditService.log_action(
action="device.updated",
action=AuditAction.DEVICE_UPDATED,
user_id=user_id,
organization_id=device.organization_id,
resource_type="device",
@@ -167,20 +168,20 @@ def remove_device(device_id: str, user_id: str) -> None:
raise DeviceNotFoundError("Device not found.")
# Soft-delete all memberships (deactivates active ones first)
for membership in device.memberships:
if membership.deleted_at is None:
from gatehouse_app.services.network_access_service import revoke_membership_soft
revoke_membership_soft(membership.id, revoked_by_user_id=user_id)
for request in device.network_access_requests:
if request.deleted_at is None:
from gatehouse_app.services.network_access_service import revoke_request_soft
revoke_request_soft(request.id, revoker_user_id=user_id)
device.delete(soft=True)
AuditService.log_action(
action="device.removed",
action=AuditAction.DEVICE_REMOVED,
user_id=user_id,
organization_id=device.organization_id,
resource_type="device",
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",
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")
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(
inviter_name: str,
org_name: str,
+59 -19
View File
@@ -403,19 +403,19 @@ class MfaPolicyService:
)
@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.
Args:
now: Current time, defaults to now
Returns:
Number of users transitioned to suspended
List of dicts with suspended record details (user, compliance, org_policy)
"""
if now is None:
now = datetime.now(timezone.utc)
suspended_count = 0
suspended_records = []
# Find all compliance records that are past due
past_due_records = MfaPolicyCompliance.query.filter(
@@ -437,21 +437,65 @@ class MfaPolicyService:
# Update user status
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
db.session.commit()
# Audit log
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
user_id=record.user_id,
organization_id=record.organization_id,
description=f"User suspended due to MFA compliance deadline passed",
)
# Get org policy for extended details
org_policy = OrganizationSecurityPolicy.query.filter_by(
organization_id=record.organization_id, deleted_at=None
).first()
suspended_count += 1
days_overdue = (now - deadline).days if deadline else 0
return suspended_count
# Audit log for user (with extended details)
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
user_id=record.user_id,
organization_id=record.organization_id,
resource_type="user",
resource_id=record.user_id,
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",
},
)
# 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,
},
)
suspended_records.append({
"user": user,
"compliance": record,
"org_policy": org_policy,
})
return suspended_records
@staticmethod
def create_org_policy(
@@ -871,11 +915,9 @@ class MfaPolicyService:
org_ids = [org.organization_id for org in suspended_orgs]
AuditService.log_action(
action=AuditAction.USER_LOGIN,
action=AuditAction.LOGIN_BLOCKED_COMPLIANCE,
user_id=user.id,
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}",
success=False,
error_message="MFA compliance required",
@@ -900,10 +942,8 @@ class MfaPolicyService:
user_agent: Client user agent
"""
AuditService.log_action(
action=AuditAction.USER_LOGIN, # Reusing USER_LOGIN for audit
action=AuditAction.MFA_COMPLIANCE_BYPASS_ATTEMPT,
user_id=user.id,
ip_address=ip_address,
user_agent=user_agent,
resource_type="endpoint",
resource_id=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 (
build_mfa_deadline_reminder_html,
build_mfa_suspension_html,
build_mfa_suspension_admin_html,
)
from gatehouse_app.utils.constants import AuditAction
@@ -123,7 +124,7 @@ class NotificationService:
f"({days_until_deadline} days remaining)"
)
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
action=AuditAction.MFA_NOTIFICATION_SENT,
user_id=user.id,
organization_id=compliance.organization_id,
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}")
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
action=AuditAction.MFA_SUSPENSION_NOTIFICATION_SENT,
user_id=user.id,
organization_id=compliance.organization_id,
description="MFA compliance suspension notification sent",
@@ -209,6 +210,100 @@ class NotificationService:
)
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
def _build_deadline_reminder_body(
user: User,
+1 -1
View File
@@ -246,7 +246,7 @@ def handle_login_callback(
auth_method.save()
AuditService.log_action(
action="user.register",
action=AuditAction.USER_REGISTER,
user_id=user.id,
organization_id=state_record.organization_id,
resource_type="user",
@@ -142,7 +142,7 @@ def handle_register_callback(
state_record.mark_used()
AuditService.log_action(
action="user.register",
action=AuditAction.USER_REGISTER,
user_id=user.id,
organization_id=state_record.organization_id,
resource_type="user",
@@ -353,7 +353,7 @@ class OrganizationService:
resource_type="organization_member",
resource_id=member.id,
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
@@ -398,7 +398,7 @@ class OrganizationService:
resource_type="organization_member",
resource_id=member.id,
metadata={"removed_user_id": user_id},
description="Member removed from organization",
description=f"Member {user_id} removed from organization",
)
@staticmethod
@@ -438,7 +438,7 @@ class OrganizationService:
"old_role": old_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
@@ -6,10 +6,11 @@ import re
from gatehouse_app.extensions import db
from gatehouse_app.models import PortalNetwork
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.services.audit_service import AuditService
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 (
NetworkNotFoundError,
InvalidNetworkIdError,
@@ -110,7 +111,7 @@ def create_network(
deleted.save()
AuditService.log_action(
action="zt.network.restored",
action=AuditAction.ZT_NETWORK_RESTORED,
user_id=owner_user_id,
organization_id=organization_id,
resource_type="portal_network",
@@ -157,7 +158,7 @@ def create_network(
)
AuditService.log_action(
action="zt.network.created",
action=AuditAction.ZT_NETWORK_CREATED,
user_id=owner_user_id,
organization_id=organization_id,
resource_type="portal_network",
@@ -178,14 +179,29 @@ def create_network(
def list_networks(
organization_id: str,
include_inactive: bool = False,
user_id: str | None = None,
) -> 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(
PortalNetwork.organization_id == organization_id,
PortalNetwork.deleted_at.is_(None),
)
if not include_inactive:
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()
@@ -246,7 +262,7 @@ def update_network(
network.update(**kwargs)
AuditService.log_action(
action="zt.network.updated",
action=AuditAction.ZT_NETWORK_UPDATED,
user_id=user_id,
organization_id=network.organization_id,
resource_type="portal_network",
@@ -262,51 +278,37 @@ def update_network(
def delete_network(network_id: str, user_id: str) -> None:
"""Soft-delete a portal network and deactivate/clean up all related records."""
from datetime import datetime, timezone
from gatehouse_app.models import UserNetworkApproval
from gatehouse_app.extensions import db
network = get_network(network_id)
# Deauthorize all active memberships in ZeroTier
for membership in network.memberships:
if membership.deleted_at is None and membership.state.value == "active_authorized":
from gatehouse_app.services.network_access_service import deactivate_membership
deactivate_membership(membership.id, reason="network_deleted")
for request in network.access_requests:
if request.deleted_at is None and request.active:
from gatehouse_app.services.network_access_service import deactivate_request
deactivate_request(request.id, reason="network_deleted")
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)
db.session.execute(
db.text(
"UPDATE user_network_approvals AS a "
"UPDATE network_access_requests AS a "
"SET deleted_at = :now + (s.rn * interval '1 microsecond') "
"FROM ("
" 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"
") s "
"WHERE a.id = s.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()
AuditService.log_action(
action="zt.network.deleted",
action=AuditAction.ZT_NETWORK_DELETED,
user_id=user_id,
organization_id=network.organization_id,
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:
"""Return all DeviceNetworkMemberships for a network with user and device info."""
from gatehouse_app.models import DeviceNetworkMembership
"""Return all approved NetworkAccessRequests for a network (active or inactive)."""
from gatehouse_app.models import NetworkAccessRequest
from gatehouse_app.utils.constants import ApprovalState
return DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.portal_network_id == network_id,
DeviceNetworkMembership.deleted_at.is_(None),
).all()
members = (
NetworkAccessRequest.query
.options(
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:
"""Return pending UserNetworkApprovals for a network."""
from gatehouse_app.models import UserNetworkApproval
"""Return pending NetworkAccessRequests for a network."""
from gatehouse_app.models import NetworkAccessRequest
from gatehouse_app.utils.constants import ApprovalState
return UserNetworkApproval.query.filter(
UserNetworkApproval.portal_network_id == network_id,
UserNetworkApproval.state == ApprovalState.PENDING,
UserNetworkApproval.deleted_at.is_(None),
return NetworkAccessRequest.query.filter(
NetworkAccessRequest.portal_network_id == network_id,
NetworkAccessRequest.status == ApprovalState.PENDING,
NetworkAccessRequest.deleted_at.is_(None),
).all()
+7
View File
@@ -105,6 +105,7 @@ class UserService:
- Session (all active sessions killed)
- OIDCAuthCode (pending auth codes invalidated)
- OIDCRefreshToken (refresh tokens invalidated)
- OAuthState (OAuth flow states invalidated)
- OIDCSession (OIDC sessions killed)
- OIDCTokenMetadata (token metadata hidden)
@@ -120,6 +121,7 @@ class UserService:
"""
from datetime import datetime, timezone
from gatehouse_app.extensions import db as _db
from gatehouse_app.models.auth.authentication_method import OAuthState
if soft:
now = datetime.now(timezone.utc)
@@ -169,6 +171,11 @@ class UserService:
pass
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 ---------------------------------------------------
for session in user.sessions:
if session.deleted_at is None:
@@ -7,17 +7,16 @@ from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models import (
Device,
DeviceNetworkMembership,
NetworkAccessRequest,
ActivationSession,
ZeroTierMembership,
PortalNetwork,
UserNetworkApproval,
)
from gatehouse_app.services import zerotier_api_service as zt
from gatehouse_app.utils.constants import (
ActivationEndReason,
MembershipState,
ApprovalState,
AuditAction,
)
logger = logging.getLogger(__name__)
@@ -45,7 +44,7 @@ def reconcile_expired_activations() -> int:
except Exception as exc:
logger.error(
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,
)
@@ -104,9 +103,9 @@ def reconcile_network(portal_network_id: str) -> dict:
# Get our portal memberships for this network
our_memberships = {
m.device.node_id: m
for m in DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.portal_network_id == portal_network_id,
DeviceNetworkMembership.deleted_at.is_(None),
for m in NetworkAccessRequest.query.filter(
NetworkAccessRequest.portal_network_id == portal_network_id,
NetworkAccessRequest.deleted_at.is_(None),
).all()
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
logger.debug(
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
@@ -134,11 +133,11 @@ def reconcile_network(portal_network_id: str) -> dict:
_sync_zt_membership(membership, zt_member)
# Sync authorization state
if membership.state == MembershipState.ACTIVE_AUTHORIZED:
if membership.active:
if not zt_member.is_authorized:
# Portal says active but ZT disagrees — drift, re-authorize
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."
)
try:
@@ -154,13 +153,13 @@ def reconcile_network(portal_network_id: str) -> dict:
)
else:
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:
if zt_member.is_authorized:
# ZT says authorized but portal doesn't — could be manual override in ZT console
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."
)
try:
@@ -177,7 +176,7 @@ def reconcile_network(portal_network_id: str) -> dict:
else:
logger.debug(
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
@@ -261,11 +260,11 @@ def reconcile_deleted_memberships() -> dict:
"""Find soft-deleted memberships and hard-delete them after ZeroTier cleanup.
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.
"""
deleted = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.deleted_at.isnot(None),
deleted = NetworkAccessRequest.query.filter(
NetworkAccessRequest.deleted_at.isnot(None),
).all()
if not deleted:
@@ -328,7 +327,7 @@ def reconcile_deleted_memberships() -> dict:
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."""
device = membership.device
network = membership.portal_network
@@ -347,7 +346,7 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
)
zt_membership = ZeroTierMembership(
organization_id=membership.organization_id,
device_network_membership_id=membership.id,
network_access_request_id=membership.id,
zerotier_network_id=network.zerotier_network_id,
node_id=device.node_id,
)
@@ -377,10 +376,10 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
logger.info(
f"[Reconciliation] First join seen for node {device.node_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.state = MembershipState.JOINED_DEAUTHORIZED
membership.active = False
membership.save()
else:
logger.debug(
@@ -397,23 +396,22 @@ def _expire_session(session: ActivationSession) -> None:
logger.info(
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()})."
)
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
if not membership:
request = NetworkAccessRequest.query.get(session.network_access_request_id)
if not request:
logger.warning(
f"[Reconciliation] Session {session.id}: membership "
f"{session.device_network_membership_id} not found — skipping ZT deauth."
f"[Reconciliation] Session {session.id}: request "
f"{session.network_access_request_id} not found — skipping ZT deauth."
)
else:
membership.state = MembershipState.ACTIVATION_EXPIRED
membership.currently_authorized = False
membership.save()
request.active = False
request.save()
device = Device.query.get(membership.device_id)
network = PortalNetwork.query.get(membership.portal_network_id)
device = Device.query.get(request.device_id)
network = PortalNetwork.query.get(request.portal_network_id)
if device and network:
network_label = f"{network.name} ({network.zerotier_network_id})"
try:
@@ -449,18 +447,18 @@ def _expire_session(session: ActivationSession) -> None:
else:
logger.warning(
f"[Reconciliation] Session {session.id}: missing "
f"{'device' if not device else 'network'} for membership "
f"{membership.id} — ZT deauth skipped."
f"{'device' if not device else 'network'} for request "
f"{request.id} — ZT deauth skipped."
)
from gatehouse_app.services.audit_service import AuditService
AuditService.log_action(
action="zt.activation.expired",
action=AuditAction.ZT_ACTIVATION_EXPIRED,
user_id=session.user_id,
organization_id=session.organization_id,
resource_type="activation_session",
resource_id=session.id,
metadata={"membership_id": session.device_network_membership_id},
metadata={"request_id": session.network_access_request_id},
description="Activation session expired",
success=True,
)
+36 -15
View File
@@ -71,9 +71,17 @@ class AuditAction(str, Enum):
USER_HARD_DELETE = "user.hard_delete"
USER_SUSPEND = "user.suspend"
USER_UNSUSPEND = "user.unsuspend"
USER_RESTORE = "user.restore"
PASSWORD_CHANGE = "user.password_change"
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
ORG_CREATE = "org.create"
ORG_UPDATE = "org.update"
@@ -183,6 +191,34 @@ class AuditAction(str, Enum):
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):
"""OIDC grant types."""
@@ -281,21 +317,6 @@ class ApprovalState(str, Enum):
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):
"""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(f" Expired activations: {result['expired_activations']}")
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()
@@ -29,8 +29,7 @@ def upgrade():
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_application_provider_configs_provider_type'), 'application_provider_configs', ['provider_type'], unique=True)
op.create_table('oidc_jwks_keys',
@@ -63,8 +62,7 @@ def upgrade():
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_organizations_slug'), 'organizations', ['slug'], unique=True)
op.create_table('users',
@@ -81,8 +79,7 @@ def upgrade():
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_activation_key'), 'users', ['activation_key'], unique=True)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
@@ -105,8 +102,7 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_audit_org', 'audit_logs', ['organization_id', 'created_at'], unique=False)
op.create_index('idx_audit_resource', 'audit_logs', ['resource_type', 'resource_id'], unique=False)
@@ -135,7 +131,6 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'method_type', 'provider_user_id', name='uix_user_method_provider')
)
op.create_index('idx_user_method', 'authentication_methods', ['user_id', 'method_type'], unique=False)
@@ -165,7 +160,6 @@ def upgrade():
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('fingerprint'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'name', name='uix_org_ca_name')
)
op.create_index('idx_ca_org_active', 'cas', ['organization_id', 'is_active'], unique=False)
@@ -182,7 +176,6 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'name', name='uix_org_dept_name')
)
op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=False)
@@ -202,8 +195,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_devices_node_id'), 'devices', ['node_id'], unique=False)
op.create_index(op.f('ix_devices_organization_id'), 'devices', ['organization_id'], unique=False)
@@ -218,8 +210,7 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_email_verification_tokens_token'), 'email_verification_tokens', ['token'], unique=True)
op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False)
@@ -242,7 +233,6 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_type')
)
op.create_index('idx_provider_config_org', 'external_provider_configs', ['organization_id', 'provider_type'], unique=False)
@@ -262,8 +252,7 @@ def upgrade():
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['target_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['triggered_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_kill_switch_events_organization_id'), 'kill_switch_events', ['organization_id'], unique=False)
op.create_index(op.f('ix_kill_switch_events_target_user_id'), 'kill_switch_events', ['target_user_id'], unique=False)
@@ -285,7 +274,6 @@ def upgrade():
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org_compliance')
)
op.create_index(op.f('ix_mfa_policy_compliance_organization_id'), 'mfa_policy_compliance', ['organization_id'], unique=False)
@@ -310,8 +298,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oauth_states_expires_at'), 'oauth_states', ['expires_at'], unique=False)
op.create_index(op.f('ix_oauth_states_organization_id'), 'oauth_states', ['organization_id'], unique=False)
@@ -340,8 +327,7 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oidc_clients_client_id'), 'oidc_clients', ['client_id'], unique=True)
op.create_index(op.f('ix_oidc_clients_organization_id'), 'oidc_clients', ['organization_id'], unique=False)
@@ -359,8 +345,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['invited_by_id'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_org_invite_tokens_email'), 'org_invite_tokens', ['email'], unique=False)
op.create_index(op.f('ix_org_invite_tokens_organization_id'), 'org_invite_tokens', ['organization_id'], unique=False)
@@ -379,8 +364,7 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_api_key_last_used', 'organization_api_keys', ['last_used_at'], unique=False)
op.create_index('idx_org_api_key_org_active', 'organization_api_keys', ['organization_id', 'is_revoked'], unique=False)
@@ -402,7 +386,6 @@ def upgrade():
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org')
)
op.create_index(op.f('ix_organization_members_organization_id'), 'organization_members', ['organization_id'], unique=False)
@@ -421,7 +404,6 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_override_type')
)
op.create_index(op.f('ix_organization_provider_overrides_organization_id'), 'organization_provider_overrides', ['organization_id'], unique=False)
@@ -439,8 +421,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_organization_security_policies_organization_id'), 'organization_security_policies', ['organization_id'], unique=True)
op.create_table('password_reset_tokens',
@@ -453,8 +434,7 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False)
@@ -476,7 +456,6 @@ def upgrade():
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'zerotier_network_id', name='uix_org_zt_network_id')
)
op.create_index(op.f('ix_portal_networks_organization_id'), 'portal_networks', ['organization_id'], unique=False)
@@ -491,7 +470,6 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'name', name='uix_org_principal_name')
)
op.create_index(op.f('ix_principals_name'), 'principals', ['name'], unique=False)
@@ -513,8 +491,7 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sessions_token'), 'sessions', ['token'], unique=True)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
@@ -536,7 +513,6 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('payload')
)
op.create_index('idx_ssh_key_user_verified', 'ssh_keys', ['user_id', 'verified'], unique=False)
@@ -556,7 +532,6 @@ def upgrade():
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org_policy')
)
op.create_index(op.f('ix_user_security_policies_organization_id'), 'user_security_policies', ['organization_id'], unique=False)
@@ -572,8 +547,7 @@ def upgrade():
sa.ForeignKeyConstraint(['ca_id'], ['cas.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('ca_id', 'user_id', name='uix_ca_permission'),
sa.UniqueConstraint('id')
sa.UniqueConstraint('ca_id', 'user_id', name='uix_ca_permission')
)
op.create_index(op.f('ix_ca_permissions_ca_id'), 'ca_permissions', ['ca_id'], unique=False)
op.create_index(op.f('ix_ca_permissions_user_id'), 'ca_permissions', ['user_id'], unique=False)
@@ -589,8 +563,7 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_department_cert_policies_department_id'), 'department_cert_policies', ['department_id'], unique=True)
op.create_table('department_memberships',
@@ -603,7 +576,6 @@ def upgrade():
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'department_id', name='uix_user_dept')
)
op.create_index(op.f('ix_department_memberships_department_id'), 'department_memberships', ['department_id'], unique=False)
@@ -618,8 +590,7 @@ def upgrade():
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('department_id', 'principal_id', name='uix_dept_principal'),
sa.UniqueConstraint('id')
sa.UniqueConstraint('department_id', 'principal_id', name='uix_dept_principal')
)
op.create_index(op.f('ix_department_principals_department_id'), 'department_principals', ['department_id'], unique=False)
op.create_index(op.f('ix_department_principals_principal_id'), 'department_principals', ['principal_id'], unique=False)
@@ -640,8 +611,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oidc_audit_logs_client_id'), 'oidc_audit_logs', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_audit_logs_event_type'), 'oidc_audit_logs', ['event_type'], unique=False)
@@ -668,8 +638,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oidc_authorization_codes_client_id'), 'oidc_authorization_codes', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_authorization_codes_expires_at'), 'oidc_authorization_codes', ['expires_at'], unique=False)
@@ -693,8 +662,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oidc_refresh_tokens_access_token_id'), 'oidc_refresh_tokens', ['access_token_id'], unique=False)
op.create_index(op.f('ix_oidc_refresh_tokens_client_id'), 'oidc_refresh_tokens', ['client_id'], unique=False)
@@ -718,8 +686,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oidc_sessions_client_id'), 'oidc_sessions', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_sessions_expires_at'), 'oidc_sessions', ['expires_at'], unique=False)
@@ -755,7 +722,6 @@ def upgrade():
sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'principal_id', name='uix_user_principal')
)
op.create_index(op.f('ix_principal_memberships_principal_id'), 'principal_memberships', ['principal_id'], unique=False)
@@ -787,8 +753,7 @@ def upgrade():
sa.ForeignKeyConstraint(['ssh_key_id'], ['ssh_keys.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('ca_id', 'serial', name='uq_ssh_certificates_ca_serial'),
sa.UniqueConstraint('id')
sa.UniqueConstraint('ca_id', 'serial', name='uq_ssh_certificates_ca_serial')
)
op.create_index('idx_cert_revoked', 'ssh_certificates', ['revoked', 'revoked_at'], unique=False)
op.create_index('idx_cert_user_status', 'ssh_certificates', ['user_id', 'status'], unique=False)
@@ -816,7 +781,6 @@ def upgrade():
sa.ForeignKeyConstraint(['portal_network_id'], ['portal_networks.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'portal_network_id', 'deleted_at', name='uix_user_network_approval')
)
op.create_index(op.f('ix_user_network_approvals_organization_id'), 'user_network_approvals', ['organization_id'], unique=False)
@@ -840,8 +804,7 @@ def upgrade():
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['certificate_id'], ['ssh_certificates.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_cert_audit_cert_action', 'certificate_audit_logs', ['certificate_id', 'action'], unique=False)
op.create_index('idx_cert_audit_user', 'certificate_audit_logs', ['user_id', 'created_at'], unique=False)
@@ -868,8 +831,7 @@ def upgrade():
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['user_network_approval_id'], ['user_network_approvals.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('device_id', 'portal_network_id', 'deleted_at', name='uix_device_network'),
sa.UniqueConstraint('id')
sa.UniqueConstraint('device_id', 'portal_network_id', 'deleted_at', name='uix_device_network')
)
op.create_index(op.f('ix_device_network_memberships_device_id'), 'device_network_memberships', ['device_id'], unique=False)
op.create_index(op.f('ix_device_network_memberships_organization_id'), 'device_network_memberships', ['organization_id'], unique=False)
@@ -894,8 +856,7 @@ def upgrade():
sa.ForeignKeyConstraint(['device_network_membership_id'], ['device_network_memberships.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_activation_sessions_device_network_membership_id'), 'activation_sessions', ['device_network_membership_id'], unique=False)
op.create_index(op.f('ix_activation_sessions_organization_id'), 'activation_sessions', ['organization_id'], unique=False)
@@ -917,7 +878,6 @@ def upgrade():
sa.ForeignKeyConstraint(['device_network_membership_id'], ['device_network_memberships.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('zerotier_network_id', 'node_id', name='uix_zt_network_node')
)
op.create_index(op.f('ix_zerotier_memberships_device_network_membership_id'), 'zerotier_memberships', ['device_network_membership_id'], unique=False)
@@ -24,7 +24,7 @@ def upgrade():
# Create index on organization_id
op.create_index(
'idx_cert_audit_org',
op.f('ix_certificate_audit_logs_organization_id'),
'certificate_audit_logs',
['organization_id']
)
@@ -44,7 +44,7 @@ def downgrade():
op.drop_constraint('fk_cert_audit_log_organization', 'certificate_audit_logs', type_='foreignkey')
# Drop index
op.drop_index('idx_cert_audit_org', 'certificate_audit_logs')
op.drop_index(op.f('ix_certificate_audit_logs_organization_id'), 'certificate_audit_logs')
# Drop organization_id column
op.drop_column('certificate_audit_logs', 'organization_id')
+78 -90
View File
@@ -17,96 +17,84 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'activation_sessions', ['id'])
op.create_unique_constraint(None, 'application_provider_configs', ['id'])
op.create_unique_constraint(None, 'audit_logs', ['id'])
op.create_unique_constraint(None, 'authentication_methods', ['id'])
op.create_unique_constraint(None, 'ca_permissions', ['id'])
op.create_unique_constraint(None, 'cas', ['id'])
op.create_unique_constraint(None, 'certificate_audit_logs', ['id'])
op.create_unique_constraint(None, 'department_cert_policies', ['id'])
op.create_unique_constraint(None, 'department_memberships', ['id'])
op.create_unique_constraint(None, 'department_principals', ['id'])
op.create_unique_constraint(None, 'departments', ['id'])
op.create_unique_constraint(None, 'device_network_memberships', ['id'])
op.create_unique_constraint(None, 'devices', ['id'])
op.create_unique_constraint(None, 'email_verification_tokens', ['id'])
op.create_unique_constraint(None, 'external_provider_configs', ['id'])
op.create_unique_constraint(None, 'kill_switch_events', ['id'])
op.create_unique_constraint(None, 'mfa_policy_compliance', ['id'])
op.create_unique_constraint(None, 'oauth_states', ['id'])
op.create_unique_constraint(None, 'oidc_audit_logs', ['id'])
op.create_unique_constraint(None, 'oidc_authorization_codes', ['id'])
op.create_unique_constraint(None, 'oidc_clients', ['id'])
op.create_unique_constraint(None, 'oidc_refresh_tokens', ['id'])
op.create_unique_constraint(None, 'oidc_sessions', ['id'])
op.create_unique_constraint(None, 'org_invite_tokens', ['id'])
op.create_unique_constraint(None, 'organization_api_keys', ['id'])
op.create_unique_constraint(None, 'organization_members', ['id'])
op.create_unique_constraint(None, 'organization_provider_overrides', ['id'])
op.create_unique_constraint(None, 'organization_security_policies', ['id'])
op.create_unique_constraint(None, 'organizations', ['id'])
op.create_unique_constraint(None, 'password_reset_tokens', ['id'])
op.create_unique_constraint(None, 'portal_networks', ['id'])
op.create_unique_constraint(None, 'principal_memberships', ['id'])
op.create_unique_constraint(None, 'principals', ['id'])
op.create_unique_constraint(None, 'sessions', ['id'])
op.create_unique_constraint(None, 'ssh_certificates', ['id'])
op.create_unique_constraint(None, 'ssh_keys', ['id'])
op.create_unique_constraint(None, 'superadmin_audit_logs', ['id'])
op.create_unique_constraint(None, 'superadmin_sessions', ['id'])
op.create_unique_constraint(None, 'superadmins', ['id'])
op.create_unique_constraint(None, 'user_network_approvals', ['id'])
op.create_unique_constraint(None, 'user_security_policies', ['id'])
op.create_unique_constraint(None, 'users', ['id'])
op.create_unique_constraint(None, 'zerotier_memberships', ['id'])
# ### end Alembic commands ###
# --- Create superadmin tables (not captured by auto-generation) ---
op.create_table(
'superadmins',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.Column('last_login_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_superadmins_email'), 'superadmins', ['email'], unique=True)
op.create_table(
'superadmin_sessions',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('superadmin_id', sa.String(length=36), nullable=False),
sa.Column('token', sa.String(length=255), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_activity_at', sa.DateTime(), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('revoked_reason', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['superadmin_id'], ['superadmins.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_superadmin_sessions_superadmin_id'), 'superadmin_sessions', ['superadmin_id'])
op.create_index(op.f('ix_superadmin_sessions_token'), 'superadmin_sessions', ['token'], unique=True)
op.create_table(
'superadmin_audit_logs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('superadmin_id', sa.String(length=36), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('resource_type', sa.String(length=50), nullable=False),
sa.Column('resource_id', sa.String(length=36), nullable=True),
sa.Column('org_id', sa.String(length=36), nullable=True),
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('request_id', sa.String(length=100), nullable=True),
sa.Column('extra_data', sa.JSON(), nullable=True),
sa.Column('success', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.Column('error_message', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['superadmin_id'], ['superadmins.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_superadmin_audit_logs_superadmin_id'), 'superadmin_audit_logs', ['superadmin_id'])
op.create_index(op.f('ix_superadmin_audit_logs_action'), 'superadmin_audit_logs', ['action'])
op.create_index(op.f('ix_superadmin_audit_logs_resource_type'), 'superadmin_audit_logs', ['resource_type'])
op.create_index(op.f('ix_superadmin_audit_logs_resource_id'), 'superadmin_audit_logs', ['resource_id'])
op.create_index(op.f('ix_superadmin_audit_logs_org_id'), 'superadmin_audit_logs', ['org_id'])
op.create_index(op.f('ix_superadmin_audit_logs_user_id'), 'superadmin_audit_logs', ['user_id'])
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'zerotier_memberships', type_='unique')
op.drop_constraint(None, 'users', type_='unique')
op.drop_constraint(None, 'user_security_policies', type_='unique')
op.drop_constraint(None, 'user_network_approvals', type_='unique')
op.drop_constraint(None, 'superadmins', type_='unique')
op.drop_constraint(None, 'superadmin_sessions', type_='unique')
op.drop_constraint(None, 'superadmin_audit_logs', type_='unique')
op.drop_constraint(None, 'ssh_keys', type_='unique')
op.drop_constraint(None, 'ssh_certificates', type_='unique')
op.drop_constraint(None, 'sessions', type_='unique')
op.drop_constraint(None, 'principals', type_='unique')
op.drop_constraint(None, 'principal_memberships', type_='unique')
op.drop_constraint(None, 'portal_networks', type_='unique')
op.drop_constraint(None, 'password_reset_tokens', type_='unique')
op.drop_constraint(None, 'organizations', type_='unique')
op.drop_constraint(None, 'organization_security_policies', type_='unique')
op.drop_constraint(None, 'organization_provider_overrides', type_='unique')
op.drop_constraint(None, 'organization_members', type_='unique')
op.drop_constraint(None, 'organization_api_keys', type_='unique')
op.drop_constraint(None, 'org_invite_tokens', type_='unique')
op.drop_constraint(None, 'oidc_sessions', type_='unique')
op.drop_constraint(None, 'oidc_refresh_tokens', type_='unique')
op.drop_constraint(None, 'oidc_clients', type_='unique')
op.drop_constraint(None, 'oidc_authorization_codes', type_='unique')
op.drop_constraint(None, 'oidc_audit_logs', type_='unique')
op.drop_constraint(None, 'oauth_states', type_='unique')
op.drop_constraint(None, 'mfa_policy_compliance', type_='unique')
op.drop_constraint(None, 'kill_switch_events', type_='unique')
op.drop_constraint(None, 'external_provider_configs', type_='unique')
op.drop_constraint(None, 'email_verification_tokens', type_='unique')
op.drop_constraint(None, 'devices', type_='unique')
op.drop_constraint(None, 'device_network_memberships', type_='unique')
op.drop_constraint(None, 'departments', type_='unique')
op.drop_constraint(None, 'department_principals', type_='unique')
op.drop_constraint(None, 'department_memberships', type_='unique')
op.drop_constraint(None, 'department_cert_policies', type_='unique')
op.drop_constraint(None, 'certificate_audit_logs', type_='unique')
op.drop_constraint(None, 'cas', type_='unique')
op.drop_constraint(None, 'ca_permissions', type_='unique')
op.drop_constraint(None, 'authentication_methods', type_='unique')
op.drop_constraint(None, 'audit_logs', type_='unique')
op.drop_constraint(None, 'application_provider_configs', type_='unique')
op.drop_constraint(None, 'activation_sessions', type_='unique')
# ### end Alembic commands ###
# --- Drop superadmin tables (reverse order due to FK dependencies) ---
op.drop_index(op.f('ix_superadmin_audit_logs_user_id'), table_name='superadmin_audit_logs')
op.drop_index(op.f('ix_superadmin_audit_logs_org_id'), table_name='superadmin_audit_logs')
op.drop_index(op.f('ix_superadmin_audit_logs_resource_id'), table_name='superadmin_audit_logs')
op.drop_index(op.f('ix_superadmin_audit_logs_resource_type'), table_name='superadmin_audit_logs')
op.drop_index(op.f('ix_superadmin_audit_logs_action'), table_name='superadmin_audit_logs')
op.drop_index(op.f('ix_superadmin_audit_logs_superadmin_id'), table_name='superadmin_audit_logs')
op.drop_table('superadmin_audit_logs')
op.drop_index(op.f('ix_superadmin_sessions_token'), table_name='superadmin_sessions')
op.drop_index(op.f('ix_superadmin_sessions_superadmin_id'), table_name='superadmin_sessions')
op.drop_table('superadmin_sessions')
op.drop_index(op.f('ix_superadmins_email'), table_name='superadmins')
op.drop_table('superadmins')
@@ -38,7 +38,7 @@ def upgrade():
is_compliance_only, created_at, updated_at, deleted_at
)
SELECT
id, 'superadmin', superadmin_id, token, 'active',
id, 'superadmin', superadmin_id, token, 'ACTIVE',
ip_address, user_agent, NULL,
expires_at, last_activity_at, revoked_at, revoked_reason,
FALSE, created_at, updated_at, deleted_at
@@ -0,0 +1,71 @@
"""Remove sudo: drop can_sudo column and organization_api_keys table.
Revision ID: d1e2f3g4h5i6
Revises: c0a1b2c3d4e5
Create Date: 2026-05-03 10:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd1e2f3g4h5i6'
down_revision = 'c0a1b2c3d4e5'
branch_labels = None
depends_on = None
def upgrade():
# ------------------------------------------------------------------
# Step 1: Drop organization_api_keys table and all its indexes
# ------------------------------------------------------------------
op.drop_index('idx_api_key_last_used', table_name='organization_api_keys')
op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys')
op.drop_index(op.f('ix_organization_api_keys_is_revoked'), table_name='organization_api_keys')
op.drop_index(op.f('ix_organization_api_keys_key_hash'), table_name='organization_api_keys')
op.drop_index(op.f('ix_organization_api_keys_organization_id'), table_name='organization_api_keys')
op.drop_table('organization_api_keys')
# ------------------------------------------------------------------
# Step 2: Drop can_sudo column from departments table
# ------------------------------------------------------------------
op.drop_column('departments', 'can_sudo')
def downgrade():
# ------------------------------------------------------------------
# Step 1: Recreate can_sudo column in departments table
# ------------------------------------------------------------------
op.add_column(
'departments',
sa.Column('can_sudo', sa.Boolean(), nullable=False, server_default='false')
)
# ------------------------------------------------------------------
# Step 2: Recreate organization_api_keys table
# ------------------------------------------------------------------
op.create_table(
'organization_api_keys',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('key_hash', sa.String(length=255), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('is_revoked', sa.Boolean(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('revoke_reason', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], name='fk_organization_api_keys_organization'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key_hash', name='uq_organization_api_keys_key_hash'),
)
# Recreate indexes on organization_api_keys
op.create_index('idx_org_api_key_org_active', 'organization_api_keys', ['organization_id', 'is_revoked'])
op.create_index('idx_api_key_last_used', 'organization_api_keys', ['last_used_at'])
op.create_index(op.f('ix_organization_api_keys_is_revoked'), 'organization_api_keys', ['is_revoked'])
op.create_index(op.f('ix_organization_api_keys_key_hash'), 'organization_api_keys', ['key_hash'], unique=True)
op.create_index(op.f('ix_organization_api_keys_organization_id'), 'organization_api_keys', ['organization_id'])
@@ -0,0 +1,23 @@
"""Merge branches: consolidate_sessions + remove_sudo_api_keys.
Revision ID: e1f2a3b4c5d6
Revises: c8d2e4f6a1b3, d1e2f3g4h5i6
Create Date: 2026-05-19 12:45:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e1f2a3b4c5d6'
down_revision = ('c8d2e4f6a1b3', 'd1e2f3g4h5i6')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -0,0 +1,707 @@
"""Merge user_network_approvals and device_network_memberships into network_access_requests.
Revision ID: c0a1b2c3d4e5
Revises: a1b2c3d4e5f6
Create Date: 2026-05-02 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'c0a1b2c3d4e5'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# UPGRADE
# ---------------------------------------------------------------------------
def upgrade():
# ------------------------------------------------------------------
# Step 0: Ensure enum types exist (they may already exist from old tables)
# ------------------------------------------------------------------
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'approval_grant_type') THEN
CREATE TYPE approval_grant_type AS ENUM ('requested', 'assigned');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'approval_state') THEN
CREATE TYPE approval_state AS ENUM ('pending', 'approved', 'rejected', 'revoked', 'suspended');
END IF;
END$$;
""")
# ------------------------------------------------------------------
# Step 1: Create the new network_access_requests table
# ------------------------------------------------------------------
op.create_table(
'network_access_requests',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('device_id', sa.String(length=36), nullable=False),
sa.Column('portal_network_id', sa.String(length=36), nullable=False),
sa.Column('granted_by_user_id', sa.String(length=36), nullable=True),
sa.Column(
'grant_type',
postgresql.ENUM('requested', 'assigned', name='approval_grant_type', create_type=False),
nullable=False,
),
sa.Column(
'status',
postgresql.ENUM(
'pending', 'approved', 'rejected', 'revoked', 'suspended',
name='approval_state', create_type=False,
),
nullable=False,
),
sa.Column('active', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('justification', sa.Text(), nullable=True),
sa.Column('join_seen', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['device_id'], ['devices.id'],
name='fk_network_access_requests_device',
),
sa.ForeignKeyConstraint(
['granted_by_user_id'], ['users.id'],
name='fk_network_access_requests_granted_by_user',
),
sa.ForeignKeyConstraint(
['organization_id'], ['organizations.id'],
name='fk_network_access_requests_organization',
),
sa.ForeignKeyConstraint(
['portal_network_id'], ['portal_networks.id'],
name='fk_network_access_requests_portal_network',
),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'],
name='fk_network_access_requests_user',
),
sa.PrimaryKeyConstraint('id', name='pk_network_access_requests'),
sa.UniqueConstraint(
'user_id', 'device_id', 'portal_network_id', 'deleted_at',
name='uix_user_device_network',
),
)
# Indexes on network_access_requests
op.create_index(
'ix_network_access_requests_device_id',
'network_access_requests',
['device_id'],
unique=False,
)
op.create_index(
'ix_network_access_requests_organization_id',
'network_access_requests',
['organization_id'],
unique=False,
)
op.create_index(
'ix_network_access_requests_portal_network_id',
'network_access_requests',
['portal_network_id'],
unique=False,
)
op.create_index(
'ix_network_access_requests_status',
'network_access_requests',
['status'],
unique=False,
)
op.create_index(
'ix_network_access_requests_user_id',
'network_access_requests',
['user_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 2: Migrate data from old tables into the new table
# ------------------------------------------------------------------
op.execute(
"""
INSERT INTO network_access_requests (
id, organization_id, user_id, device_id, portal_network_id,
granted_by_user_id, grant_type, status, active, justification,
join_seen, created_at, updated_at, deleted_at
)
SELECT
dnm.id,
dnm.organization_id,
dnm.user_id,
dnm.device_id,
dnm.portal_network_id,
COALESCE(una.granted_by_user_id, NULL),
COALESCE(una.grant_type, 'requested'),
COALESCE(una.state, 'pending'),
CASE
WHEN dnm.currently_authorized = true AND una.state = 'approved'
THEN true
ELSE false
END,
una.justification,
dnm.join_seen,
COALESCE(dnm.created_at, una.created_at),
COALESCE(dnm.updated_at, una.updated_at),
dnm.deleted_at
FROM device_network_memberships dnm
LEFT JOIN user_network_approvals una
ON una.id = dnm.user_network_approval_id;
"""
)
# ------------------------------------------------------------------
# Step 3: Update activation_sessions FK
# ------------------------------------------------------------------
# 3a. Add the new nullable column
op.add_column(
'activation_sessions',
sa.Column('network_access_request_id', sa.String(length=36), nullable=True),
)
# 3b. Populate the new column from the old column
op.execute(
"""
UPDATE activation_sessions
SET network_access_request_id = device_network_membership_id;
"""
)
# 3c. Drop the old foreign-key constraint
op.drop_constraint(
'activation_sessions_device_network_membership_id_fkey',
'activation_sessions',
type_='foreignkey',
)
# 3d. Drop the old column
op.drop_column('activation_sessions', 'device_network_membership_id')
# 3d-alt. Enforce NOT NULL on the new column before FK creation
op.alter_column('activation_sessions', 'network_access_request_id', nullable=False)
# 3e. Create the new foreign-key constraint
op.create_foreign_key(
'fk_activation_sessions_network_access_request',
'activation_sessions',
'network_access_requests',
['network_access_request_id'],
['id'],
)
# 3f. Create the new index
op.create_index(
'ix_activation_sessions_network_access_request_id',
'activation_sessions',
['network_access_request_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 4: Update zerotier_memberships FK
# ------------------------------------------------------------------
# 4a. Add the new nullable column
op.add_column(
'zerotier_memberships',
sa.Column('network_access_request_id', sa.String(length=36), nullable=True),
)
# 4b. Populate the new column from the old column
op.execute(
"""
UPDATE zerotier_memberships
SET network_access_request_id = device_network_membership_id;
"""
)
# 4c. Drop the old foreign-key constraint
op.drop_constraint(
'zerotier_memberships_device_network_membership_id_fkey',
'zerotier_memberships',
type_='foreignkey',
)
# 4d. Drop the old column
op.drop_column('zerotier_memberships', 'device_network_membership_id')
# 4e. Create the new foreign-key constraint
op.create_foreign_key(
'fk_zerotier_memberships_network_access_request',
'zerotier_memberships',
'network_access_requests',
['network_access_request_id'],
['id'],
)
# 4f. Create the new index
op.create_index(
'ix_zerotier_memberships_network_access_request_id',
'zerotier_memberships',
['network_access_request_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 5: Drop old tables and the membership_state enum
# ------------------------------------------------------------------
# 5a. Drop device_network_memberships and all its indexes
op.drop_index(
'ix_device_network_memberships_user_network_approval_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_user_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_state',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_portal_network_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_organization_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_device_id',
table_name='device_network_memberships',
)
op.drop_table('device_network_memberships')
# 5b. Drop user_network_approvals and all its indexes
op.drop_index(
'ix_user_network_approvals_user_id',
table_name='user_network_approvals',
)
op.drop_index(
'ix_user_network_approvals_state',
table_name='user_network_approvals',
)
op.drop_index(
'ix_user_network_approvals_portal_network_id',
table_name='user_network_approvals',
)
op.drop_index(
'ix_user_network_approvals_organization_id',
table_name='user_network_approvals',
)
op.drop_table('user_network_approvals')
# 5c. Drop the membership_state enum type if it exists
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'membership_state'
) THEN
DROP TYPE membership_state;
END IF;
END$$;
"""
)
# ---------------------------------------------------------------------------
# DOWNGRADE
# ---------------------------------------------------------------------------
def downgrade():
# ------------------------------------------------------------------
# Step 1: Recreate the membership_state enum (used by old tables)
# ------------------------------------------------------------------
membership_state = sa.Enum(
'pending_device_registration',
'pending_request',
'pending_manager_approval',
'approved_inactive',
'joined_deauthorized',
'active_authorized',
'activation_expired',
'suspended',
'revoked',
'rejected',
name='membership_state',
)
membership_state.create(op.get_bind(), checkfirst=True)
# ------------------------------------------------------------------
# Step 2: Recreate user_network_approvals table
# ------------------------------------------------------------------
op.create_table(
'user_network_approvals',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('portal_network_id', sa.String(length=36), nullable=False),
sa.Column('granted_by_user_id', sa.String(length=36), nullable=True),
sa.Column(
'grant_type',
postgresql.ENUM('requested', 'assigned', name='approval_grant_type', create_type=False),
nullable=False,
),
sa.Column(
'state',
postgresql.ENUM(
'pending', 'approved', 'rejected', 'revoked', 'suspended',
name='approval_state', create_type=False,
),
nullable=False,
),
sa.Column('justification', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['granted_by_user_id'], ['users.id'],
),
sa.ForeignKeyConstraint(
['organization_id'], ['organizations.id'],
),
sa.ForeignKeyConstraint(
['portal_network_id'], ['portal_networks.id'],
),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'],
),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'user_id', 'portal_network_id', 'deleted_at',
name='uix_user_network_approval',
),
)
# Recreate indexes on user_network_approvals
op.create_index(
'ix_user_network_approvals_organization_id',
'user_network_approvals',
['organization_id'],
unique=False,
)
op.create_index(
'ix_user_network_approvals_portal_network_id',
'user_network_approvals',
['portal_network_id'],
unique=False,
)
op.create_index(
'ix_user_network_approvals_state',
'user_network_approvals',
['state'],
unique=False,
)
op.create_index(
'ix_user_network_approvals_user_id',
'user_network_approvals',
['user_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 3: Migrate data back into user_network_approvals
# ------------------------------------------------------------------
# Derive one approval row per (user_id, portal_network_id, deleted_at).
# We use gen_random_uuid() to generate new approval IDs because the
# original approval IDs were lost during the upgrade.
op.execute(
"""
INSERT INTO user_network_approvals (
id, organization_id, user_id, portal_network_id,
granted_by_user_id, grant_type, state, justification,
created_at, updated_at, deleted_at
)
SELECT
gen_random_uuid()::text,
(array_agg(organization_id ORDER BY created_at))[1],
user_id,
portal_network_id,
(array_agg(granted_by_user_id ORDER BY created_at))[1],
(array_agg(grant_type ORDER BY created_at))[1],
(array_agg(status ORDER BY created_at))[1],
(array_agg(justification ORDER BY created_at))[1],
MIN(created_at),
MAX(updated_at),
deleted_at
FROM network_access_requests
GROUP BY user_id, portal_network_id, deleted_at;
"""
)
# ------------------------------------------------------------------
# Step 4: Recreate device_network_memberships table
# ------------------------------------------------------------------
op.create_table(
'device_network_memberships',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('device_id', sa.String(length=36), nullable=False),
sa.Column('portal_network_id', sa.String(length=36), nullable=False),
sa.Column('user_network_approval_id', sa.String(length=36), nullable=True),
sa.Column(
'state',
postgresql.ENUM(
'pending_device_registration',
'pending_request',
'pending_manager_approval',
'approved_inactive',
'joined_deauthorized',
'active_authorized',
'activation_expired',
'suspended',
'revoked',
'rejected',
name='membership_state', create_type=False,
),
nullable=False,
),
sa.Column('join_seen', sa.Boolean(), nullable=False),
sa.Column('currently_authorized', sa.Boolean(), nullable=False),
sa.Column('approved_for_activation', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['device_id'], ['devices.id'],
),
sa.ForeignKeyConstraint(
['organization_id'], ['organizations.id'],
),
sa.ForeignKeyConstraint(
['portal_network_id'], ['portal_networks.id'],
),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'],
),
sa.ForeignKeyConstraint(
['user_network_approval_id'], ['user_network_approvals.id'],
),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'device_id', 'portal_network_id', 'deleted_at',
name='uix_device_network',
),
)
# Recreate indexes on device_network_memberships
op.create_index(
'ix_device_network_memberships_device_id',
'device_network_memberships',
['device_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_organization_id',
'device_network_memberships',
['organization_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_portal_network_id',
'device_network_memberships',
['portal_network_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_state',
'device_network_memberships',
['state'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_user_id',
'device_network_memberships',
['user_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_user_network_approval_id',
'device_network_memberships',
['user_network_approval_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 5: Migrate data back into device_network_memberships
# ------------------------------------------------------------------
# Map network_access_requests rows back to device_network_memberships.
# Reverse the status/active mapping using a best-effort approach.
op.execute(
"""
INSERT INTO device_network_memberships (
id, organization_id, user_id, device_id, portal_network_id,
user_network_approval_id, state, join_seen, currently_authorized,
approved_for_activation, created_at, updated_at, deleted_at
)
SELECT
nar.id,
nar.organization_id,
nar.user_id,
nar.device_id,
nar.portal_network_id,
una.id AS user_network_approval_id,
CASE nar.status
WHEN 'approved' THEN
CASE WHEN nar.active = true
THEN 'active_authorized'
ELSE 'approved_inactive'
END
WHEN 'pending' THEN 'pending_request'
ELSE nar.status
END AS state,
nar.join_seen,
nar.active AS currently_authorized,
CASE
WHEN nar.status = 'approved' THEN true
ELSE false
END AS approved_for_activation,
nar.created_at,
nar.updated_at,
nar.deleted_at
FROM network_access_requests nar
JOIN user_network_approvals una
ON una.user_id = nar.user_id
AND una.portal_network_id = nar.portal_network_id
AND (una.deleted_at IS NOT DISTINCT FROM nar.deleted_at);
"""
)
# ------------------------------------------------------------------
# Step 6: Restore activation_sessions FK
# ------------------------------------------------------------------
# 6a. Add the old column (nullable first so we can populate)
op.add_column(
'activation_sessions',
sa.Column('device_network_membership_id', sa.String(length=36), nullable=True),
)
# 6b. Populate the old column from the new column before it disappears
op.execute(
"""
UPDATE activation_sessions
SET device_network_membership_id = network_access_request_id
WHERE network_access_request_id IS NOT NULL;
"""
)
# 6c. Drop the new column, FK, and index
op.drop_constraint(
'fk_activation_sessions_network_access_request',
'activation_sessions',
type_='foreignkey',
)
op.drop_index(
'ix_activation_sessions_network_access_request_id',
table_name='activation_sessions',
)
op.drop_column('activation_sessions', 'network_access_request_id')
# 6d. Alter the old column to NOT NULL
op.alter_column(
'activation_sessions',
'device_network_membership_id',
nullable=False,
)
# 6d. Recreate the old foreign key
op.create_foreign_key(
None,
'activation_sessions',
'device_network_memberships',
['device_network_membership_id'],
['id'],
)
# 6e. Recreate the old index
op.create_index(
'ix_activation_sessions_device_network_membership_id',
'activation_sessions',
['device_network_membership_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 7: Restore zerotier_memberships FK
# ------------------------------------------------------------------
# 7a. Add the old column (nullable first so we can populate)
op.add_column(
'zerotier_memberships',
sa.Column('device_network_membership_id', sa.String(length=36), nullable=True),
)
# 7b. Populate the old column from the new column before it disappears
op.execute(
"""
UPDATE zerotier_memberships
SET device_network_membership_id = network_access_request_id
WHERE network_access_request_id IS NOT NULL;
"""
)
# 7c. Drop the new column, FK, and index
op.drop_constraint(
'fk_zerotier_memberships_network_access_request',
'zerotier_memberships',
type_='foreignkey',
)
op.drop_index(
'ix_zerotier_memberships_network_access_request_id',
table_name='zerotier_memberships',
)
op.drop_column('zerotier_memberships', 'network_access_request_id')
# 7d. Recreate the old foreign key
op.create_foreign_key(
None,
'zerotier_memberships',
'device_network_memberships',
['device_network_membership_id'],
['id'],
)
# 7e. Recreate the old index
op.create_index(
'ix_zerotier_memberships_device_network_membership_id',
'zerotier_memberships',
['device_network_membership_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 8: Drop the new network_access_requests table and indexes
# ------------------------------------------------------------------
op.drop_index(
'ix_network_access_requests_user_id',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_status',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_portal_network_id',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_organization_id',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_device_id',
table_name='network_access_requests',
)
op.drop_table('network_access_requests')
+15
View File
@@ -48,6 +48,21 @@ class AdminClient:
data={"confirm": confirm},
)
def get_user_ssh_certificates(self, user_id: str, **params) -> dict:
"""List all SSH certificates for a user (admin view).
Args:
user_id: Target user ID
**params: Optional query parameters status, active, cert_type, page, per_page
"""
path = f"/admin/users/{user_id}/ssh-certificates"
if params:
from urllib.parse import urlencode
query = urlencode({k: v for k, v in params.items() if v is not None})
if query:
path = f"{path}?{query}"
return self._client.get(path)
def list_audit_logs(self) -> dict:
"""List system-wide audit logs."""
return self._client.get("/audit-logs")
+348
View File
@@ -211,3 +211,351 @@ class TestAdminUserManagement:
with pytest.raises(ApiError) as exc_info:
integration_client.auth.login(email=victim["email"], password="VictimPass123!")
assert exc_info.value.status_code in (400, 401)
def test_admin_soft_delete_cascades_org_membership(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership):
"""TEST: ADMIN-12 — Admin soft-delete cascades to org memberships.
WHAT: Admin soft-deletes a user in an org. The membership row
and the user row both get soft-deleted.
WHY: Ghost memberships (membership active but user deleted) would
make Organization.is_member() return True and break lookups.
EXPECTED: 200 OK. OrganizationMember.deleted_at and User.deleted_at
are set. Organization.is_member() returns False.
"""
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.models.user.user import User
from gatehouse_app.extensions import db
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.hard_delete_user(victim["id"], confirm=True)
assert_success(result)
with integration_app.app_context():
# Membership row still exists but is soft-deleted
membership = OrganizationMember.query.filter_by(
user_id=victim["id"], organization_id=org["id"]
).first()
assert membership is not None
assert membership.deleted_at is not None
# User row is soft-deleted
user = User.query.get(victim["id"])
assert user is not None
assert user.deleted_at is not None
# Organization.is_member() returns False (defense in depth)
org_obj = Organization.query.get(org["id"])
assert org_obj.is_member(victim["id"]) is False
class TestAdminSSHCertificates:
"""Test admin SSH certificate listing endpoints."""
def _create_test_cert(
self, integration_app, user_id: str, ca_id: str, *, ssh_key_id=None,
status="issued", revoked=False, valid_after=None, valid_before=None,
cert_type="user", principals=None,
):
"""Create a test SSH certificate record."""
from datetime import datetime, timezone, timedelta
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ssh_ca.ca import CertType
now = datetime.now(timezone.utc)
valid_after = valid_after or (now - timedelta(hours=1))
valid_before = valid_before or (now + timedelta(hours=23))
principals = principals or ["prod-servers"]
with integration_app.app_context():
cert = SSHCertificate(
ca_id=ca_id,
user_id=user_id,
ssh_key_id=ssh_key_id,
certificate=f"ssh-ed25519-cert-v01@openssh.com AAAA...test_serial_{uuid.uuid4().hex[:8]}",
serial=str(uuid.uuid4().int)[:20],
key_id=f"test@example.com-{uuid.uuid4().hex[:8]}",
cert_type=CertType(cert_type),
principals=principals,
valid_after=valid_after,
valid_before=valid_before,
revoked=revoked,
status=CertificateStatus(status),
request_ip="192.168.1.100",
request_user_agent="OpenSSH_9.0",
)
if revoked:
cert.revoked_at = now
cert.revoke_reason = "test revocation"
db.session.add(cert)
db.session.commit()
return str(cert.id)
def _create_test_ssh_key(self, integration_app, user_id: str, fingerprint: str = None):
"""Create a test SSH key record."""
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
fingerprint = fingerprint or f"SHA256:{uuid.uuid4().hex[:43]}"
with integration_app.app_context():
key = SSHKey(
user_id=user_id,
payload=f"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...test",
fingerprint=fingerprint,
description="Test laptop key",
verified=True,
key_type="ssh-ed25519",
key_bits=256,
key_comment="test@laptop",
)
db.session.add(key)
db.session.commit()
return str(key.id)
def test_list_user_ssh_certs_positive(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-01 — List all SSH certificates for a user as admin.
WHAT: Create a user with two certs (one active, one expired),
admin lists all certs via the new endpoint.
WHY: Admin needs full visibility of user SSH certificate history.
EXPECTED: 200 OK with certificates array containing both certs.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
# Create an active cert
self._create_test_cert(
integration_app, victim["id"], ca["id"],
status="issued", valid_after=now - timedelta(hours=1),
valid_before=now + timedelta(hours=23),
)
# Create an expired cert
self._create_test_cert(
integration_app, victim["id"], ca["id"],
status="expired", valid_after=now - timedelta(days=7),
valid_before=now - timedelta(days=1),
)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
assert "certificates" in data
assert data["count"] == 2
assert len(data["certificates"]) == 2
def test_list_user_ssh_certs_with_key_metadata(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-02 — Certificate includes SSH key metadata.
WHAT: Create a cert linked to an SSH key, verify key details
appear in the response.
WHY: Admin needs to see which key was used to request the cert.
EXPECTED: ssh_key object with fingerprint, key_type, key_bits.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
key_id = self._create_test_ssh_key(integration_app, victim["id"])
self._create_test_cert(integration_app, victim["id"], ca["id"], ssh_key_id=key_id)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
cert = data["certificates"][0]
assert cert["ssh_key"] is not None
assert cert["ssh_key"]["key_type"] == "ssh-ed25519"
assert cert["ssh_key"]["fingerprint"] is not None
assert cert["ssh_key"]["description"] == "Test laptop key"
def test_list_user_ssh_certs_non_admin_negative(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-03 — Non-admin cannot list another user's certs.
WHAT: Regular member tries to list admin's certs.
WHY: Certificate data is sensitive and admin-only.
EXPECTED: 403 Forbidden.
"""
member = create_test_user(password="MemberPass123!")
admin_user = create_test_user(password="AdminPass123!")
org = create_test_org()
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
create_test_membership(admin_user["id"], org["id"], OrganizationRole.OWNER)
integration_client.auth.login(email=member["email"], password="MemberPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.admin.get_user_ssh_certificates(admin_user["id"])
assert exc_info.value.status_code == 403
def test_list_user_ssh_certs_filter_by_status(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-04 — Filter certificates by status.
WHAT: Create certs with different statuses, filter by status=revoked.
WHY: Admin may want to see only revoked certs to audit access.
EXPECTED: Only revoked certs returned.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
self._create_test_cert(integration_app, victim["id"], ca["id"], status="issued")
self._create_test_cert(integration_app, victim["id"], ca["id"], status="revoked", revoked=True)
self._create_test_cert(integration_app, victim["id"], ca["id"], status="expired")
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"], status="revoked")
data = assert_success(result)
assert data["count"] == 1
assert data["certificates"][0]["status"] == "revoked"
assert data["certificates"][0]["revoked"] is True
def test_list_user_ssh_certs_filter_active_only(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-05 — Filter for only currently valid certificates.
WHAT: Create active and expired certs, filter by active=true.
WHY: Admin needs quick view of currently active certs.
EXPECTED: Only valid (non-revoked, non-expired) certs returned.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
self._create_test_cert(
integration_app, victim["id"], ca["id"], status="issued",
valid_after=now - timedelta(hours=1), valid_before=now + timedelta(hours=23),
)
self._create_test_cert(
integration_app, victim["id"], ca["id"], status="expired",
valid_after=now - timedelta(days=7), valid_before=now - timedelta(days=1),
)
self._create_test_cert(
integration_app, victim["id"], ca["id"], status="revoked", revoked=True,
valid_after=now - timedelta(hours=1), valid_before=now + timedelta(hours=23),
)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"], active="true")
data = assert_success(result)
assert data["count"] == 1
cert = data["certificates"][0]
assert cert["is_valid"] is True
assert cert["revoked"] is False
def test_list_user_ssh_certs_user_not_found(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership):
"""TEST: ADMIN-SSH-06 — Return 404 for non-existent user.
WHAT: Admin requests certs for a user ID that doesn't exist.
WHY: Clear error for missing resources.
EXPECTED: 404 NOT_FOUND.
"""
admin = create_test_user(password="AdminPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.admin.get_user_ssh_certificates("non-existent-user-id")
assert exc_info.value.status_code == 404
assert exc_info.value.error_type == "NOT_FOUND"
def test_list_user_ssh_certs_empty_result(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership):
"""TEST: ADMIN-SSH-07 — Empty result when user has no certs.
WHAT: Admin lists certs for a user who has never requested one.
WHY: Endpoint should handle gracefully, not error.
EXPECTED: 200 OK with empty certificates array and count=0.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
assert data["certificates"] == []
assert data["count"] == 0
def test_list_user_ssh_certs_revoked_cert_details(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-08 — Revoked certificate shows revocation details.
WHAT: Create a revoked cert, verify revoke metadata is present.
WHY: Admin needs to know when and why a cert was revoked.
EXPECTED: revoked=True, revoked_at populated, revoke_reason present.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
self._create_test_cert(
integration_app, victim["id"], ca["id"],
status="revoked", revoked=True,
)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
cert = data["certificates"][0]
assert cert["revoked"] is True
assert cert["revoked_at"] is not None
assert cert["revoke_reason"] == "test revocation"
assert cert["status"] == "revoked"
def test_list_user_ssh_certs_invalid_status_filter(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership):
"""TEST: ADMIN-SSH-09 — Invalid status filter returns 400.
WHAT: Admin passes an invalid status value.
WHY: Input validation prevents confusing queries.
EXPECTED: 400 VALIDATION_ERROR.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.admin.get_user_ssh_certificates(victim["id"], status="bogus")
assert exc_info.value.status_code == 400
assert exc_info.value.error_type == "VALIDATION_ERROR"
+552
View File
@@ -0,0 +1,552 @@
"""CLI integration tests.
Runs ``gatehouse-cli.py`` as a subprocess against a real Flask server
with a file-based SQLite database. Each test is self-contained:
it creates its own user, org, CA, and SSH keys via the API, then
caches the auth token and invokes the CLI.
"""
import json
import os
import socket
import subprocess
import sys
import threading
import time
import uuid
import pytest
import requests
from gatehouse_app import create_app
from gatehouse_app.extensions import db
# ---------------------------------------------------------------------------
# Module-scoped server fixture
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def cli_server(tmp_path_factory):
"""Flask test server on ``127.0.0.1`` with a random port + temp SQLite DB.
Yields ``(server_url, app)``. A single DB file is shared by all tests
in the module; each test uses UUID-suffixed names so there are no
collisions.
"""
server_dir = tmp_path_factory.mktemp("cli-server")
db_path = server_dir / "test.db"
session_dir = server_dir / "sessions"
session_dir.mkdir()
app = create_app(config_name="testing")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SESSION_FILE_DIR"] = str(session_dir)
with app.app_context():
db.create_all()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
server_url = f"http://127.0.0.1:{port}"
t = threading.Thread(
target=lambda: app.run(
host="127.0.0.1", port=port, use_reloader=False, debug=False,
),
daemon=True,
)
t.start()
for _ in range(50):
try:
requests.get(f"{server_url}/api/v1/auth/login", timeout=1)
break
except requests.ConnectionError:
time.sleep(0.1)
else:
raise RuntimeError("Server did not start in time")
yield server_url, app
# ---------------------------------------------------------------------------
# Per-test home-directory fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def home_dir(tmp_path):
"""Isolated ``~/.gatehouse`` + ``~/.ssh`` per test."""
return tmp_path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _register_user(server_url, email, password, full_name="Test User"):
"""Register a user via ``POST /api/v1/auth/register``, return (user, token)."""
resp = requests.post(
f"{server_url}/api/v1/auth/register",
json={
"email": email,
"password": password,
"password_confirm": password,
"full_name": full_name,
},
)
assert resp.status_code == 201, (
f"Register failed ({resp.status_code}): {resp.text}"
)
data = resp.json()["data"]
return data["user"], data["token"]
def _auth_headers(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def _create_org(server_url, token, name):
"""Create an org (caller becomes owner)."""
resp = requests.post(
f"{server_url}/api/v1/organizations",
json={"name": name, "slug": name.lower().replace(" ", "-")},
headers=_auth_headers(token),
)
assert resp.status_code == 201, (
f"Create org failed ({resp.status_code}): {resp.text}"
)
return resp.json()["data"]["organization"]
def _create_ca(server_url, token, org_id, ca_type="user"):
"""Create a real CA via ``POST /organizations/{id}/cas``."""
resp = requests.post(
f"{server_url}/api/v1/organizations/{org_id}/cas",
json={
"name": f"Test-CA-{uuid.uuid4().hex[:6]}",
"ca_type": ca_type,
"key_type": "ed25519",
},
headers=_auth_headers(token),
)
assert resp.status_code == 201, (
f"Create CA failed ({resp.status_code}): {resp.text}"
)
return resp.json()["data"]["ca"]
def _create_principal(server_url, token, org_id, name="deploy"):
"""Create a principal in an org."""
resp = requests.post(
f"{server_url}/api/v1/organizations/{org_id}/principals",
json={"name": name, "description": f"Principal {name}"},
headers=_auth_headers(token),
)
assert resp.status_code == 201, (
f"Create principal failed ({resp.status_code}): {resp.text}"
)
return resp.json()["data"]["principal"]
def _add_principal_member(server_url, token, org_id, principal_id, email):
"""Add a user as a direct member of a principal (``POST .../members``)."""
resp = requests.post(
f"{server_url}/api/v1/organizations/{org_id}/principals/{principal_id}/members",
json={"email": email},
headers=_auth_headers(token),
)
# 201 = created; 409 = already exists (OK)
assert resp.status_code in (201, 409), (
f"Add principal member failed ({resp.status_code}): {resp.text}"
)
def _add_ssh_key(server_url, token, public_key):
"""Add an SSH public key via the API."""
resp = requests.post(
f"{server_url}/api/v1/ssh/keys",
json={"key": public_key, "description": "CLI test key"},
headers=_auth_headers(token),
)
assert resp.status_code == 201, (
f"Add SSH key failed ({resp.status_code}): {resp.text}"
)
return resp.json()["data"]
def _mark_key_verified(app, key_id):
"""Bypass signature check — mark a key as ``verified`` directly in the DB."""
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
with app.app_context():
key = db.session.get(SSHKey, key_id)
if key:
key.verified = True
key.verified_at = __import__(
"datetime"
).datetime.now(__import__("pytz").UTC)
db.session.commit()
def _cache_token(home_dir, token):
"""Write the token to ``~/.gatehouse/token_cache.json``."""
cache_dir = home_dir / ".gatehouse"
cache_dir.mkdir(exist_ok=True)
(cache_dir / "token_cache.json").write_text(json.dumps({"token": token}))
def _run_cli(server_url, home_dir, *args):
"""Run ``gatehouse-cli.py`` as a subprocess, return ``CompletedProcess``."""
env = os.environ.copy()
env["SIGN_URL"] = server_url
env["HOME"] = str(home_dir)
proc = subprocess.run(
[sys.executable, "client/gatehouse-cli.py", *args],
capture_output=True,
text=True,
env=env,
timeout=30,
)
return proc
def _output(result: subprocess.CompletedProcess) -> str:
"""Combine stdout + stderr for message-level assertions.
The CLI emits via coloredlogs (stderr) and occasional ``print()``
calls (stdout), so we check both.
"""
return result.stdout + result.stderr
def _cleanup_shared_files():
"""Remove temp files the CLI writes to known paths."""
for p in ("/tmp/challenge.txt", "/tmp/challenge.txt.sig", "/tmp/ssh-cert"):
if os.path.exists(p):
os.remove(p)
# ---- unique-name helpers ---------------------------------------------------
def _uniq(prefix="test"):
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _email():
return f"{_uniq('cli')}@example.com"
# ===========================================================================
# Tests
# ===========================================================================
class TestCLI:
"""CLI integration tests — each test exercises a different subcommand."""
# ------------------------------------------------------------------
# 1. Add SSH key (real ssh-keygen flow)
# ------------------------------------------------------------------
def test_cli_add_key(self, cli_server, home_dir):
"""``gatehouse-cli.py -a -k <pubkey>`` — add + verify a key."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
# Generate a real Ed25519 keypair
key_path = os.path.join(home_dir, "id_ed25519")
gen = subprocess.run(
["ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "", "-C", email],
capture_output=True,
)
if gen.returncode != 0:
pytest.skip(f"ssh-keygen not available: {gen.stderr.decode()}")
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "-a", "-k", key_path + ".pub")
assert result.returncode == 0, (
f"CLI add-key failed:\nstderr: {result.stderr}\nstdout: {result.stdout}"
)
assert "added successfully" in _output(result), (
f"Expected 'added successfully' in:\n{_output(result)}"
)
# ------------------------------------------------------------------
# 2. List SSH keys
# ------------------------------------------------------------------
def test_cli_list_keys(self, cli_server, home_dir):
"""``gatehouse-cli.py --list-keys`` — shows registered keys."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
# Add two SSH keys via API (using unique generated keys)
key1 = _add_ssh_key(server_url, token, self._gen_pubkey("key1"))
key2 = _add_ssh_key(server_url, token, self._gen_pubkey("key2"))
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "--list-keys")
assert result.returncode == 0, (
f"CLI list-keys failed:\n{result.stderr}"
)
assert key1["id"][:8] in _output(result), (
f"Key1 ID prefix not in output:\n{_output(result)}"
)
assert key2["id"][:8] in _output(result), (
f"Key2 ID prefix not in output:\n{_output(result)}"
)
_key_counter = 0
def _gen_pubkey(self, tag: str) -> str:
"""Return a unique-looking but structurally valid Ed25519 public key."""
unique = uuid.uuid4().hex[:32]
padding = "A" * (43 - 32)
return (
f"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI{unique}{padding} {tag}@example.com"
)
def _real_pubkey(self, tag: str) -> str:
"""Generate a real Ed25519 keypair and return the public key string."""
TestCLI._key_counter += 1
tmp = f"/tmp/cli_test_key_{TestCLI._key_counter}_{uuid.uuid4().hex[:8]}"
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-f", tmp, "-N", "", "-C", tag],
capture_output=True,
)
with open(f"{tmp}.pub") as f:
pub = f.read().strip()
for p in (tmp, f"{tmp}.pub"):
try:
os.unlink(p)
except OSError:
pass
return pub
# ------------------------------------------------------------------
# 3. Remove SSH key
# ------------------------------------------------------------------
def test_cli_remove_key(self, cli_server, home_dir):
"""``gatehouse-cli.py --remove-key <id>`` — deletes a key."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
key = _add_ssh_key(server_url, token, self._gen_pubkey("remove-me"))
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "--remove-key", key["id"])
assert result.returncode == 0, (
f"CLI remove-key failed:\n{result.stderr}"
)
assert "removed successfully" in _output(result), (
f"Expected removal confirmation in:\n{_output(result)}"
)
# ------------------------------------------------------------------
# 4. List organizations
# ------------------------------------------------------------------
def test_cli_list_orgs(self, cli_server, home_dir):
"""``gatehouse-cli.py --list-orgs`` — shows org memberships."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
org_a = _create_org(server_url, token, f"Alpha-{uuid.uuid4().hex[:6]}")
org_b = _create_org(server_url, token, f"Beta-{uuid.uuid4().hex[:6]}")
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "--list-orgs")
assert result.returncode == 0, (
f"CLI list-orgs failed:\n{result.stderr}"
)
assert org_a["name"] in _output(result), (
f"Org A name not in output:\n{_output(result)}"
)
assert org_b["name"] in _output(result), (
f"Org B name not in output:\n{_output(result)}"
)
# ------------------------------------------------------------------
# 5. Certificate signing — single org (no --org-id needed)
# ------------------------------------------------------------------
def test_cli_request_cert_single_org(self, cli_server, home_dir):
"""``gatehouse-cli.py -r`` — auto-selects the only org."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
org = _create_org(server_url, token, f"Single-{uuid.uuid4().hex[:6]}")
_create_ca(server_url, token, org["id"], ca_type="user")
princ = _create_principal(server_url, token, org["id"], name="deploy")
_add_principal_member(server_url, token, org["id"], princ["id"], email)
key = _add_ssh_key(server_url, token, self._real_pubkey("cert1"))
_mark_key_verified(app, key["id"])
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "-r")
assert result.returncode == 0, (
f"CLI request-cert failed:\nstderr: {result.stderr}\nstdout: {result.stdout}"
)
assert "Certificate signed successfully" in _output(result), (
f"Expected success in:\n{_output(result)}"
)
assert princ["name"] in _output(result), (
f"Principal name not in output:\n{_output(result)}"
)
# ------------------------------------------------------------------
# 6. Certificate signing — multi org WITHOUT --org-id
# ------------------------------------------------------------------
def test_cli_request_cert_multi_org_no_org(self, cli_server, home_dir):
"""``gatehouse-cli.py -r`` w/o ``--org-id`` — ambiguous error."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
org1 = _create_org(server_url, token, f"MultiA-{uuid.uuid4().hex[:6]}")
_create_ca(server_url, token, org1["id"], ca_type="user")
org2 = _create_org(server_url, token, f"MultiB-{uuid.uuid4().hex[:6]}")
_create_ca(server_url, token, org2["id"], ca_type="user")
princ1 = _create_principal(server_url, token, org1["id"], name="deploy")
_add_principal_member(server_url, token, org1["id"], princ1["id"], email)
key = _add_ssh_key(server_url, token, self._real_pubkey("multi"))
_mark_key_verified(app, key["id"])
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "-r")
assert result.returncode == 1, (
f"Expected exit code 1, got {result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "multiple organizations" in _output(result).lower()
# ------------------------------------------------------------------
# 7. Certificate signing — multi org WITH --org-id
# ------------------------------------------------------------------
def test_cli_request_cert_multi_org_with_org(self, cli_server, home_dir):
"""``gatehouse-cli.py -r --org-id <id>`` — specifies the org."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
org1 = _create_org(server_url, token, f"WithA-{uuid.uuid4().hex[:6]}")
_create_ca(server_url, token, org1["id"], ca_type="user")
princ1 = _create_principal(server_url, token, org1["id"], name="deploy")
_add_principal_member(server_url, token, org1["id"], princ1["id"], email)
org2 = _create_org(server_url, token, f"WithB-{uuid.uuid4().hex[:6]}")
_create_ca(server_url, token, org2["id"], ca_type="user")
key = _add_ssh_key(server_url, token, self._real_pubkey("with-org"))
_mark_key_verified(app, key["id"])
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "-r", "--org-id", org1["id"])
assert result.returncode == 0, (
f"CLI request-cert with org-id failed:\n"
f"stderr: {result.stderr}\nstdout: {result.stdout}"
)
assert "Certificate signed successfully" in _output(result)
# ------------------------------------------------------------------
# 8. Install known hosts
# ------------------------------------------------------------------
def test_cli_install_known_hosts(self, cli_server, home_dir):
"""``gatehouse-cli.py --install-known-hosts`` — fetches Host CA."""
_cleanup_shared_files()
server_url, app = cli_server
email = _email()
pw = "MyPassword123!"
_, token = _register_user(server_url, email, pw)
org = _create_org(server_url, token, f"HostCA-{uuid.uuid4().hex[:6]}")
ca = _create_ca(server_url, token, org["id"], ca_type="host")
_cache_token(home_dir, token)
result = _run_cli(server_url, home_dir, "--install-known-hosts")
assert result.returncode == 0, (
f"CLI install-known-hosts failed:\n{result.stderr}"
)
assert "Successfully installed Host CA" in _output(result)
# Verify the key was written to known_hosts
known_hosts = home_dir / ".ssh" / "known_hosts"
assert known_hosts.exists(), "known_hosts file was not created"
content = known_hosts.read_text()
assert ca["public_key"].strip() in content, (
"CA public key not found in known_hosts"
)
# ------------------------------------------------------------------
# 9. Clear cache
# ------------------------------------------------------------------
def test_cli_clear_cache(self, cli_server, home_dir):
"""``gatehouse-cli.py --clear-cache`` — removes cached token."""
# Create a bogus cache file first
cache_dir = home_dir / ".gatehouse"
cache_dir.mkdir(exist_ok=True)
(cache_dir / "token_cache.json").write_text('{"token":"dummy"}')
result = _run_cli("http://localhost:0", home_dir, "--clear-cache")
assert result.returncode == 0, (
f"CLI clear-cache failed:\n{result.stderr}"
)
assert "Cached token removed" in _output(result)
assert not (cache_dir / "token_cache.json").exists()
# ------------------------------------------------------------------
# 10. Check certificate (no cert file)
# ------------------------------------------------------------------
def test_cli_check_cert(self, cli_server, home_dir):
"""``gatehouse-cli.py -c`` — reports missing cert."""
_cleanup_shared_files()
result = _run_cli("http://localhost:0", home_dir, "-c")
assert result.returncode == 1, (
f"Expected exit code 1, got {result.returncode}\n"
f"stdout: {result.stdout}"
)
assert "Certificate does not exist" in _output(result)
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
"""Unit tests for validate_cors_origins() helper.
WHAT: Tests for the CORS origin validation function used by OIDC client
create/update endpoints.
WHY: Malformed origins would silently break per-client CORS; strict
validation catches mistakes at write-time rather than at runtime.
EXPECTED: Valid origins accepted, invalid origins rejected with clear errors.
"""
import pytest
from gatehouse_app.utils.validators import validate_cors_origins
class TestValidateCorsOriginsAccepts:
"""Cases that should pass validation."""
def test_none_passes_through(self):
value, error = validate_cors_origins(None)
assert value is None
assert error is None
def test_empty_list_is_valid(self):
value, error = validate_cors_origins([])
assert value == []
assert error is None
def test_sentinel_plus(self):
value, error = validate_cors_origins(["+"])
assert value == ["+"]
assert error is None
def test_sentinel_wildcard(self):
value, error = validate_cors_origins(["*"])
assert value == ["*"]
assert error is None
def test_https_origin(self):
value, error = validate_cors_origins(["https://example.com"])
assert value == ["https://example.com"]
assert error is None
def test_https_origin_with_port(self):
value, error = validate_cors_origins(["https://example.com:8443"])
assert value == ["https://example.com:8443"]
assert error is None
def test_http_localhost(self):
value, error = validate_cors_origins(["http://localhost:3000"])
assert value == ["http://localhost:3000"]
assert error is None
def test_multiple_valid_origins(self):
origins = ["https://app.example.com", "https://staging.example.com:8443"]
value, error = validate_cors_origins(origins)
assert value == origins
assert error is None
def test_origin_with_trailing_slash_accepted(self):
# urlparse treats trailing "/" as path="/", which we allow
value, error = validate_cors_origins(["https://example.com/"])
assert value == ["https://example.com/"]
assert error is None
def test_sentinel_mixed_with_origins(self):
value, error = validate_cors_origins(["+", "https://extra.example.com"])
assert value == ["+", "https://extra.example.com"]
assert error is None
class TestValidateCorsOriginsRejects:
"""Cases that should fail validation."""
def test_not_a_list(self):
value, error = validate_cors_origins("https://example.com")
assert value is None
assert "must be a list" in error
def test_non_string_entry(self):
value, error = validate_cors_origins([123])
assert value is None
assert "expected a string" in error
def test_empty_string_entry(self):
value, error = validate_cors_origins([""])
assert value is None
assert "empty string" in error
def test_whitespace_only_entry(self):
value, error = validate_cors_origins([" "])
assert value is None
assert "empty string" in error
def test_origin_with_path(self):
value, error = validate_cors_origins(["https://example.com/api/v1"])
assert value is None
assert "must not contain a path" in error
def test_origin_with_query_string(self):
value, error = validate_cors_origins(["https://example.com?q=1"])
assert value is None
assert "query string" in error
def test_origin_with_fragment(self):
value, error = validate_cors_origins(["https://example.com#section"])
assert value is None
assert "fragment" in error
def test_ftp_scheme_rejected(self):
value, error = validate_cors_origins(["ftp://example.com"])
assert value is None
assert "invalid scheme" in error
def test_no_scheme(self):
value, error = validate_cors_origins(["example.com"])
assert value is None
# urlparse puts this in path, not hostname, so we get either
# scheme or hostname error
assert error is not None
def test_bare_string_not_url(self):
value, error = validate_cors_origins(["not-a-url"])
assert value is None
assert error is not None
def test_mixed_valid_and_invalid(self):
"""First invalid entry stops validation."""
value, error = validate_cors_origins([
"https://good.example.com",
"ftp://bad.example.com",
])
assert value is None
assert "allowed_cors_origins[1]" in error
def test_dict_entry_rejected(self):
value, error = validate_cors_origins([{"url": "https://example.com"}])
assert value is None
assert "expected a string" in error
@@ -0,0 +1,88 @@
"""Verify the structure of the Alembic migration that merges
user_network_approvals and device_network_memberships into network_access_requests.
These are STRUCTURAL tests only no database connection is required.
"""
import importlib
import importlib.util
import os
import sys
# ── helpers ────────────────────────────────────────────────────────────────
def _load_migration_module():
"""Load the migration module by file path without executing Alembic."""
migration_path = os.path.join(
os.path.dirname(__file__),
'..', '..', 'migrations', 'versions',
'merge_approval_membership_tables.py',
)
migration_path = os.path.abspath(migration_path)
spec = importlib.util.spec_from_file_location(
'merge_approval_membership_tables', migration_path,
)
assert spec is not None, f'Could not create module spec for {migration_path}'
assert spec.loader is not None, f'Module spec has no loader for {migration_path}'
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ── structural tests ───────────────────────────────────────────────────────
def test_migration_file_can_be_imported():
"""The migration module MUST import without raising any exception."""
mod = _load_migration_module()
assert mod is not None
def test_upgrade_function_exists():
"""upgrade() must be a callable in the module."""
mod = _load_migration_module()
assert hasattr(mod, 'upgrade'), 'module is missing upgrade()'
assert callable(mod.upgrade), 'upgrade is not callable'
def test_downgrade_function_exists():
"""downgrade() must be a callable in the module."""
mod = _load_migration_module()
assert hasattr(mod, 'downgrade'), 'module is missing downgrade()'
assert callable(mod.downgrade), 'downgrade is not callable'
def test_revision_is_set_correctly():
"""revision must equal the documented value 'c0a1b2c3d4e5'."""
mod = _load_migration_module()
assert hasattr(mod, 'revision'), 'module is missing revision'
assert mod.revision == 'c0a1b2c3d4e5', (
f"Expected revision 'c0a1b2c3d4e5', got '{mod.revision}'"
)
def test_down_revision_is_set_correctly():
"""down_revision must equal the documented value 'a1b2c3d4e5f6'."""
mod = _load_migration_module()
assert hasattr(mod, 'down_revision'), 'module is missing down_revision'
assert mod.down_revision == 'a1b2c3d4e5f6', (
f"Expected down_revision 'a1b2c3d4e5f6', got '{mod.down_revision}'"
)
def test_branch_labels_is_none():
"""branch_labels should be None for a standard linear migration."""
mod = _load_migration_module()
assert mod.branch_labels is None, (
f"Expected branch_labels None, got {mod.branch_labels!r}"
)
def test_depends_on_is_none():
"""depends_on should be None — this migration has no cross-dependencies."""
mod = _load_migration_module()
assert mod.depends_on is None, (
f"Expected depends_on None, got {mod.depends_on!r}"
)
@@ -0,0 +1,340 @@
"""Unit tests for NetworkAccessRequest model structure.
WHAT: Verifies the model class can be imported, has the expected columns,
constraints, and enum types.
WHY: Structural correctness of the model is a prerequisite for Phase 2+
work; catching missing columns or constraints early prevents
migration/runtime failures.
APPROACH: gatehouse_app/__init__.py calls create_app() at module level which
requires psycopg2 (PostgreSQL driver). We prevent this by pre-loading
gatehouse_app as a bare namespace package, then selectively providing
the real submodules (utils.constants) and fakes (extensions, models.base).
We do NOT call db.create_all() the table metadata is fully populated
during class definition. FK target tables don't exist in our test
metadata, so we check FK presence without table resolution.
"""
import sys
import importlib.util
import pytest
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# ═══════════════════════════════════════════════════════════════════════════════
# Step 1: Pre-load gatehouse_app as a bare namespace (prevents __init__.py)
# ═══════════════════════════════════════════════════════════════════════════════
_gatehouse = type(sys)("gatehouse_app")
_gatehouse.__path__ = []
sys.modules["gatehouse_app"] = _gatehouse
# ═══════════════════════════════════════════════════════════════════════════════
# Step 2: Load the real gatehouse_app.utils.constants (self-contained, no deps)
# ═══════════════════════════════════════════════════════════════════════════════
_constants_spec = importlib.util.spec_from_file_location(
"gatehouse_app.utils.constants",
"/home/ubuntu/securid/gatehouse-api/gatehouse_app/utils/constants.py",
submodule_search_locations=[],
)
_constants_mod = importlib.util.module_from_spec(_constants_spec)
sys.modules["gatehouse_app.utils"] = type(sys)("gatehouse_app.utils")
sys.modules["gatehouse_app.utils.constants"] = _constants_mod
_constants_spec.loader.exec_module(_constants_mod)
ApprovalGrantType = _constants_mod.ApprovalGrantType
ApprovalState = _constants_mod.ApprovalState
# ═══════════════════════════════════════════════════════════════════════════════
# Step 3: Build fake extensions.db and models.base
# ═══════════════════════════════════════════════════════════════════════════════
_fake_db = SQLAlchemy()
class FakeBaseModel(_fake_db.Model):
"""Minimal BaseModel matching the real one's column definitions."""
__abstract__ = True
id = _fake_db.Column(_fake_db.String(36), primary_key=True, default=lambda: "test-uuid", nullable=False)
created_at = _fake_db.Column(_fake_db.DateTime, nullable=False)
updated_at = _fake_db.Column(_fake_db.DateTime, nullable=False)
deleted_at = _fake_db.Column(_fake_db.DateTime, nullable=True)
def to_dict(self, exclude=None):
"""Mimic the real BaseModel.to_dict — iterates __table__.columns."""
from datetime import datetime, timezone
exclude = exclude or []
result = {}
for column in self.__table__.columns:
if column.name not in exclude:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.isoformat()
else:
result[column.name] = value
return result
_fake_extensions = type(sys)("gatehouse_app.extensions")
_fake_extensions.db = _fake_db
_fake_models_base = type(sys)("gatehouse_app.models.base")
_fake_models_base.BaseModel = FakeBaseModel
sys.modules["gatehouse_app.extensions"] = _fake_extensions
sys.modules["gatehouse_app.models"] = type(sys)("gatehouse_app.models")
sys.modules["gatehouse_app.models.base"] = _fake_models_base
# ═══════════════════════════════════════════════════════════════════════════════
# Step 3b: Create stub models for relationship targets so ORM mapper
# can resolve 'Organization', 'User', 'Device', 'PortalNetwork'
# ═══════════════════════════════════════════════════════════════════════════════
class Organization(_fake_db.Model):
__tablename__ = "organizations"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class User(_fake_db.Model):
__tablename__ = "users"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class Device(_fake_db.Model):
__tablename__ = "devices"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class PortalNetwork(_fake_db.Model):
__tablename__ = "portal_networks"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
# ═══════════════════════════════════════════════════════════════════════════════
# Step 4: Load the real network_access_request module from file
# ═══════════════════════════════════════════════════════════════════════════════
_model_spec = importlib.util.spec_from_file_location(
"gatehouse_app.models.zerotier.network_access_request",
"/home/ubuntu/securid/gatehouse-api/gatehouse_app/models/zerotier/network_access_request.py",
submodule_search_locations=[],
)
_model_mod = importlib.util.module_from_spec(_model_spec)
sys.modules["gatehouse_app.models.zerotier"] = type(sys)("gatehouse_app.models.zerotier")
sys.modules["gatehouse_app.models.zerotier.network_access_request"] = _model_mod
_model_spec.loader.exec_module(_model_mod)
NetworkAccessRequest = _model_mod.NetworkAccessRequest
# ═══════════════════════════════════════════════════════════════════════════════
# Fixture
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.fixture(scope="module")
def model_class():
"""Return the model class — table metadata is already built at definition time."""
return NetworkAccessRequest
@pytest.fixture(scope="module")
def app():
"""Minimal Flask app for to_dict (BaseModel.to_dict iterates __table__.columns)."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
_fake_db.init_app(app)
return app
# ═══════════════════════════════════════════════════════════════════════════════
# Test data
# ═══════════════════════════════════════════════════════════════════════════════
EXPECTED_LOCAL_COLUMNS = {
"organization_id", "user_id", "device_id", "portal_network_id",
"granted_by_user_id", "grant_type", "status", "active",
"justification", "join_seen",
}
EXPECTED_INHERITED_COLUMNS = {"id", "created_at", "updated_at", "deleted_at"}
ALL_EXPECTED = EXPECTED_LOCAL_COLUMNS | EXPECTED_INHERITED_COLUMNS
# FK columns that should have foreign keys (table name, FK target)
EXPECTED_FKS = {
"organization_id": "organizations.id",
"user_id": "users.id",
"device_id": "devices.id",
"portal_network_id": "portal_networks.id",
"granted_by_user_id": "users.id",
}
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Module importability
# ═══════════════════════════════════════════════════════════════════════════════
class TestImport:
def test_model_importable(self, model_class):
assert model_class is not None
assert isinstance(model_class, type)
def test_model_tablename(self, model_class):
assert model_class.__tablename__ == "network_access_requests"
def test_model_inherits_base(self, model_class):
assert issubclass(model_class, FakeBaseModel)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Columns
# ═══════════════════════════════════════════════════════════════════════════════
class TestColumns:
def test_all_expected_columns_present(self, model_class):
actual = {c.name for c in model_class.__table__.columns}
missing = ALL_EXPECTED - actual
assert missing == set(), f"Missing columns: {missing}"
def test_no_extra_columns(self, model_class):
actual = {c.name for c in model_class.__table__.columns}
extra = actual - ALL_EXPECTED
assert extra == set(), f"Unexpected columns: {extra}"
def test_exact_column_count(self, model_class):
assert len(model_class.__table__.columns) == 14, (
f"Expected 14 columns, got {len(model_class.__table__.columns)}: "
f"{sorted(c.name for c in model_class.__table__.columns)}"
)
def test_organization_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["organization_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_user_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["user_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_device_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["device_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_portal_network_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["portal_network_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_granted_by_user_id_nullable_fk(self, model_class):
col = model_class.__table__.columns["granted_by_user_id"]
assert col.nullable
assert _has_foreign_key(col)
def test_justification_is_text_nullable(self, model_class):
col = model_class.__table__.columns["justification"]
assert col.nullable
assert "TEXT" in str(col.type).upper()
def test_active_is_boolean_not_null(self, model_class):
col = model_class.__table__.columns["active"]
assert str(col.type) in ("BOOLEAN", "INTEGER")
assert not col.nullable
def test_join_seen_is_boolean_not_null(self, model_class):
col = model_class.__table__.columns["join_seen"]
assert str(col.type) in ("BOOLEAN", "INTEGER")
assert not col.nullable
def test_fk_count(self, model_class):
"""Verify exactly the expected FK columns have foreign keys."""
fk_cols = {c.name for c in model_class.__table__.columns if _has_foreign_key(c)}
assert fk_cols == set(EXPECTED_FKS.keys()), (
f"FK columns {sorted(fk_cols)} != expected {sorted(EXPECTED_FKS.keys())}"
)
def _has_foreign_key(column):
"""Check if column has at least one ForeignKey, without resolving target table."""
return bool(column.foreign_keys)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: UniqueConstraint
# ═══════════════════════════════════════════════════════════════════════════════
class TestConstraints:
def test_unique_constraint_exists(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) >= 1, "No UniqueConstraint found"
def test_unique_constraint_columns(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) == 1, f"Expected 1, found {len(ucs)}"
cols = {col.name for col in ucs[0].columns}
expected = {"user_id", "device_id", "portal_network_id", "deleted_at"}
assert cols == expected, f"UniqueConstraint columns {cols} != {expected}"
def test_unique_constraint_name(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) == 1
assert ucs[0].name == "uix_user_device_network", (
f"Expected 'uix_user_device_network', got '{ucs[0].name}'"
)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Enum types
# ═══════════════════════════════════════════════════════════════════════════════
class TestEnumTypes:
def test_status_column_uses_approval_state_enum(self, model_class):
col = model_class.__table__.columns["status"]
assert hasattr(col.type, "enum_class"), (
f"status column type {type(col.type)} has no enum_class"
)
assert col.type.enum_class is ApprovalState, (
f"status enum is {col.type.enum_class}, expected ApprovalState"
)
def test_grant_type_column_uses_approval_grant_type_enum(self, model_class):
col = model_class.__table__.columns["grant_type"]
assert hasattr(col.type, "enum_class"), (
f"grant_type column type {type(col.type)} has no enum_class"
)
assert col.type.enum_class is ApprovalGrantType, (
f"grant_type enum is {col.type.enum_class}, expected ApprovalGrantType"
)
def test_status_column_not_nullable(self, model_class):
assert not model_class.__table__.columns["status"].nullable
def test_grant_type_column_not_nullable(self, model_class):
assert not model_class.__table__.columns["grant_type"].nullable
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Properties and methods
# ═══════════════════════════════════════════════════════════════════════════════
class TestMethods:
def test_repr_returns_string(self, model_class):
instance = model_class()
result = repr(instance)
assert isinstance(result, str)
assert "NetworkAccessRequest" in result
def test_active_session_property_returns_none(self, model_class):
instance = model_class()
assert instance.active_session is None
def test_to_dict_returns_dict(self, model_class, app):
with app.app_context():
instance = model_class()
result = instance.to_dict()
assert isinstance(result, dict)
for col_name in EXPECTED_LOCAL_COLUMNS:
assert col_name in result, f"Missing '{col_name}' in to_dict output"
+340
View File
@@ -0,0 +1,340 @@
"""Unit tests for allowed_cors_origins in OIDC client endpoints.
WHAT: Tests that the create, update, and list endpoints correctly accept,
validate, persist, and return the allowed_cors_origins field.
WHY: The field was already on the model but was not wired into any API
endpoint; these tests verify the new wiring works end-to-end.
EXPECTED: Valid origins are stored and returned; invalid origins are rejected
with 400; omitting the field defaults to None (global fallback).
"""
import json
import secrets
from unittest.mock import patch, MagicMock
import pytest
from gatehouse_app import create_app, db
from gatehouse_app.extensions import limiter
from gatehouse_app.models.oidc.oidc_client import OIDCClient
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.user.user import User
from gatehouse_app.utils.constants import OrganizationRole
# Disable rate limiter for tests
limiter.enabled = False
@pytest.fixture(scope="module")
def app():
"""Create a test Flask app with in-memory SQLite."""
_app = create_app(config_name="testing")
_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
_app.config["TESTING"] = True
_app.config["WTF_CSRF_ENABLED"] = False
_app.config["RATELIMIT_ENABLED"] = False
with _app.app_context():
db.create_all()
yield _app
db.session.remove()
db.drop_all()
@pytest.fixture
def auth_context(app):
"""Create a user, org, and admin membership.
The module-scoped app fixture already holds an active app_context,
so we don't push another one here.
"""
user = User(
email=f"admin_{secrets.token_hex(4)}@test.com",
full_name="Admin User",
email_verified=True,
)
db.session.add(user)
db.session.commit()
org = Organization(
name=f"Test Org {secrets.token_hex(4)}",
slug=f"test-org-{secrets.token_hex(4)}",
)
db.session.add(org)
db.session.commit()
membership = OrganizationMember(
user_id=user.id,
organization_id=org.id,
role=OrganizationRole.OWNER,
)
db.session.add(membership)
db.session.commit()
return user, org
def _auth_headers():
return {"Authorization": "Bearer test-token", "Content-Type": "application/json"}
def _mock_session_for(user):
"""Return a context manager that patches SessionService to authenticate *user*."""
mock_session = MagicMock()
mock_session.user = user
mock_session.is_active.return_value = True
mock_session.is_compliance_only = False
mock_session.device_info = {}
return patch(
"gatehouse_app.services.session_service.SessionService.get_active_session_by_token",
return_value=mock_session,
)
def _create_oidc_client(org_id, **overrides):
"""Insert a minimal OIDCClient directly into the DB."""
from gatehouse_app.extensions import bcrypt
defaults = dict(
organization_id=org_id,
name="Test Client",
client_id=secrets.token_hex(16),
client_secret_hash=bcrypt.generate_password_hash("secret").decode("utf-8"),
redirect_uris=["https://app.example.com/callback"],
grant_types=["authorization_code"],
response_types=["code"],
scopes=["openid"],
is_active=True,
is_confidential=True,
)
defaults.update(overrides)
c = OIDCClient(**defaults)
db.session.add(c)
db.session.commit()
return c
# ---------------------------------------------------------------------------
# POST create
# ---------------------------------------------------------------------------
class TestCreateClientCorsOrigins:
"""POST /api/v1/organizations/<org_id>/clients with allowed_cors_origins."""
def test_create_with_cors_origins(self, app, auth_context):
user, org = auth_context
with app.test_client() as tc, _mock_session_for(user):
resp = tc.post(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
data=json.dumps({
"name": "CORS Test Client",
"redirect_uris": ["https://app.example.com/callback"],
"allowed_cors_origins": ["https://app.example.com"],
}),
)
assert resp.status_code == 201
data = resp.get_json()
assert data["data"]["client"]["allowed_cors_origins"] == ["https://app.example.com"]
def test_create_with_sentinel_plus(self, app, auth_context):
user, org = auth_context
with app.test_client() as tc, _mock_session_for(user):
resp = tc.post(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
data=json.dumps({
"name": "Plus Client",
"redirect_uris": ["https://app.example.com/callback"],
"allowed_cors_origins": ["+"],
}),
)
assert resp.status_code == 201
assert resp.get_json()["data"]["client"]["allowed_cors_origins"] == ["+"]
def test_create_without_cors_origins_defaults_to_none(self, app, auth_context):
user, org = auth_context
with app.test_client() as tc, _mock_session_for(user):
resp = tc.post(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
data=json.dumps({
"name": "No CORS Client",
"redirect_uris": ["https://app.example.com/callback"],
}),
)
assert resp.status_code == 201
assert resp.get_json()["data"]["client"]["allowed_cors_origins"] is None
def test_create_with_null_cors_origins(self, app, auth_context):
user, org = auth_context
with app.test_client() as tc, _mock_session_for(user):
resp = tc.post(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
data=json.dumps({
"name": "Null CORS Client",
"redirect_uris": ["https://app.example.com/callback"],
"allowed_cors_origins": None,
}),
)
assert resp.status_code == 201
assert resp.get_json()["data"]["client"]["allowed_cors_origins"] is None
def test_create_with_invalid_cors_origin_returns_400(self, app, auth_context):
user, org = auth_context
with app.test_client() as tc, _mock_session_for(user):
resp = tc.post(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
data=json.dumps({
"name": "Bad CORS Client",
"redirect_uris": ["https://app.example.com/callback"],
"allowed_cors_origins": ["ftp://bad.example.com"],
}),
)
assert resp.status_code == 400
assert "invalid scheme" in resp.get_json()["message"]
def test_create_with_origin_containing_path_returns_400(self, app, auth_context):
user, org = auth_context
with app.test_client() as tc, _mock_session_for(user):
resp = tc.post(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
data=json.dumps({
"name": "Path CORS Client",
"redirect_uris": ["https://app.example.com/callback"],
"allowed_cors_origins": ["https://app.example.com/api"],
}),
)
assert resp.status_code == 400
assert "must not contain a path" in resp.get_json()["message"]
def test_create_with_non_list_cors_origins_returns_400(self, app, auth_context):
user, org = auth_context
with app.test_client() as tc, _mock_session_for(user):
resp = tc.post(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
data=json.dumps({
"name": "String CORS Client",
"redirect_uris": ["https://app.example.com/callback"],
"allowed_cors_origins": "https://app.example.com",
}),
)
assert resp.status_code == 400
assert "must be a list" in resp.get_json()["message"]
# ---------------------------------------------------------------------------
# PATCH update
# ---------------------------------------------------------------------------
class TestUpdateClientCorsOrigins:
"""PATCH /api/v1/organizations/<org_id>/clients/<client_id> with allowed_cors_origins."""
def test_update_set_cors_origins(self, app, auth_context):
user, org = auth_context
oidc_client = _create_oidc_client(org.id)
assert oidc_client.allowed_cors_origins is None
with app.test_client() as tc, _mock_session_for(user):
resp = tc.patch(
f"/api/v1/organizations/{org.id}/clients/{oidc_client.id}",
headers=_auth_headers(),
data=json.dumps({
"allowed_cors_origins": ["https://new-app.example.com"],
}),
)
assert resp.status_code == 200
data = resp.get_json()["data"]["client"]
assert data["allowed_cors_origins"] == ["https://new-app.example.com"]
def test_update_clear_cors_origins(self, app, auth_context):
user, org = auth_context
oidc_client = _create_oidc_client(org.id, allowed_cors_origins=["https://old.example.com"])
with app.test_client() as tc, _mock_session_for(user):
resp = tc.patch(
f"/api/v1/organizations/{org.id}/clients/{oidc_client.id}",
headers=_auth_headers(),
data=json.dumps({
"allowed_cors_origins": None,
}),
)
assert resp.status_code == 200
assert resp.get_json()["data"]["client"]["allowed_cors_origins"] is None
def test_update_cors_origins_with_invalid_value_returns_400(self, app, auth_context):
user, org = auth_context
oidc_client = _create_oidc_client(org.id)
with app.test_client() as tc, _mock_session_for(user):
resp = tc.patch(
f"/api/v1/organizations/{org.id}/clients/{oidc_client.id}",
headers=_auth_headers(),
data=json.dumps({
"allowed_cors_origins": ["https://good.com", "not-a-url"],
}),
)
assert resp.status_code == 400
def test_update_without_cors_field_does_not_change_it(self, app, auth_context):
user, org = auth_context
oidc_client = _create_oidc_client(org.id, allowed_cors_origins=["https://keep-me.example.com"])
with app.test_client() as tc, _mock_session_for(user):
resp = tc.patch(
f"/api/v1/organizations/{org.id}/clients/{oidc_client.id}",
headers=_auth_headers(),
data=json.dumps({
"name": "Renamed",
}),
)
assert resp.status_code == 200
assert resp.get_json()["data"]["client"]["allowed_cors_origins"] == ["https://keep-me.example.com"]
def test_update_set_wildcard(self, app, auth_context):
user, org = auth_context
oidc_client = _create_oidc_client(org.id)
with app.test_client() as tc, _mock_session_for(user):
resp = tc.patch(
f"/api/v1/organizations/{org.id}/clients/{oidc_client.id}",
headers=_auth_headers(),
data=json.dumps({
"allowed_cors_origins": ["*"],
}),
)
assert resp.status_code == 200
assert resp.get_json()["data"]["client"]["allowed_cors_origins"] == ["*"]
# ---------------------------------------------------------------------------
# GET list
# ---------------------------------------------------------------------------
class TestListClientsCorsOrigins:
"""GET /api/v1/organizations/<org_id>/clients returns allowed_cors_origins."""
def test_list_includes_cors_origins(self, app, auth_context):
user, org = auth_context
oidc_client = _create_oidc_client(
org.id,
name="List Test",
allowed_cors_origins=["https://list.example.com"],
)
with app.test_client() as tc, _mock_session_for(user):
resp = tc.get(
f"/api/v1/organizations/{org.id}/clients",
headers=_auth_headers(),
)
assert resp.status_code == 200
clients_list = resp.get_json()["data"]["clients"]
matching = [c for c in clients_list if c["client_id"] == oidc_client.client_id]
assert len(matching) == 1
assert matching[0]["allowed_cors_origins"] == ["https://list.example.com"]