Compare commits
29 Commits
oidc-uplift
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d74d9316 | |||
| 05589ce442 | |||
| f002f4e495 | |||
| 66df4b6ab5 | |||
| ccd21ccde4 | |||
| 55f24ea9e5 | |||
| 2aad17f5e0 | |||
| fed72f8bcd | |||
| f869f6c06d | |||
| 13767d3fa1 | |||
| cade827b63 | |||
| 2c8160d78e | |||
| 2342a1aab6 | |||
| 78bae3c2bb | |||
| f856aa5aea | |||
| 815084132f | |||
| 417d462fb9 | |||
| 81a221bd2b | |||
| 6d794106be | |||
| c6f36ba62c | |||
| d100fdff3b | |||
| 32d517ea08 | |||
| 5b799b186f | |||
| 5d94299aaa | |||
| dfe584b60a | |||
| adfeb1bd0f | |||
| 0fb98b4b38 | |||
| 01c76ed172 | |||
| 05cf3b3840 |
+6
-1
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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` |
|
||||
@@ -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.) |
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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']}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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}>"
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user