Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -0,0 +1,204 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identity_client(tmp_path, monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.db import session as database
|
||||
from backend.app.main import app
|
||||
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'identities.db'}", connect_args={"check_same_thread": False})
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false")
|
||||
monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False)
|
||||
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"], raising=False)
|
||||
monkeypatch.setattr(settings, "auth_admin_emails", ["jens@example.com"], raising=False)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
# Avoid unrelated schedulers and external connectors in boundary tests.
|
||||
from backend.app.db.init_db import init_db
|
||||
init_db()
|
||||
yield TestClient(app, client=("198.18.10.20", 42000))
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def sign_in(client, subject, **claims):
|
||||
from backend.app.db import session as database
|
||||
from backend.app.services.user_access import issue_session, provision_user
|
||||
|
||||
with Session(database.engine) as db:
|
||||
user = provision_user(db, "authentik", "https://auth.example/application/o/appops/", {
|
||||
"sub": subject, "email": f"{subject}@example.com", **claims,
|
||||
})
|
||||
cookie = issue_session(db, user)
|
||||
user_id = user.id
|
||||
client.cookies.set("appops_user_session", cookie)
|
||||
return user_id
|
||||
|
||||
|
||||
def test_shared_token_and_disabled_operator_switch_do_not_grant_access(identity_client):
|
||||
client = identity_client
|
||||
assert client.get("/api/apps").status_code == 401
|
||||
assert client.post("/api/actions/plan", json={}, headers={"X-AppOps-Operator-Token": "old-shared-token"}).status_code == 401
|
||||
assert client.get("/api/auth/session").json()["authenticated"] is False
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_new_user_is_read_only_and_cannot_change_permissions(identity_client):
|
||||
user_id = sign_in(identity_client, "reader")
|
||||
assert identity_client.get("/api/apps").status_code == 200
|
||||
status = identity_client.get("/api/auth/session").json()
|
||||
assert status["user"]["role"] == "read_only"
|
||||
assert status["permissions"] == ["read"]
|
||||
for path in ["/api/actions/execute", "/api/voice/execute", "/api/settings/connectors/portainer", f"/api/auth/users/{user_id}"]:
|
||||
assert identity_client.post(path, json={}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 403
|
||||
assert identity_client.get("/api/auth/users").status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_manage_users_without_unlock_and_changes_apply_immediately(identity_client):
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
status = identity_client.get("/api/auth/session").json()
|
||||
assert status["user"]["role"] == "admin"
|
||||
assert "users.manage" in status["permissions"]
|
||||
assert identity_client.get("/api/auth/users").status_code == 200
|
||||
update = identity_client.patch(f"/api/auth/users/{reader_id}", json={"role": "custom", "permissions": ["read", "inventory.manage"], "enabled": True}, headers={"X-AppOps-Session-CSRF": "1"})
|
||||
assert update.status_code == 200
|
||||
sign_in(identity_client, "reader")
|
||||
assert identity_client.get("/api/auth/session").json()["permissions"] == ["inventory.manage", "read"]
|
||||
assert identity_client.post("/api/actions/execute", json={}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 403
|
||||
|
||||
|
||||
def test_csrf_logout_revocation_and_last_admin_protection(identity_client):
|
||||
admin_id = sign_in(identity_client, "owner")
|
||||
assert identity_client.patch(f"/api/auth/users/{admin_id}", json={"role": "read_only", "permissions": [], "enabled": True}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 409
|
||||
assert identity_client.post("/api/auth/logout").status_code == 403
|
||||
assert identity_client.post("/api/auth/logout", headers={"X-AppOps-Session-CSRF": "1"}).status_code == 200
|
||||
assert identity_client.get("/api/apps").status_code == 401
|
||||
|
||||
|
||||
def test_last_enabled_admin_guard_counts_another_enabled_admin(identity_client):
|
||||
admin_id = sign_in(identity_client, "owner")
|
||||
second_id = sign_in(identity_client, "second-admin")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
|
||||
promoted = identity_client.patch(
|
||||
f"/api/auth/users/{second_id}",
|
||||
json={"role": "admin", "enabled": True, "permissions": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert promoted.status_code == 200
|
||||
|
||||
disabled = identity_client.patch(
|
||||
f"/api/auth/users/{second_id}",
|
||||
json={"role": "admin", "enabled": False, "permissions": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
|
||||
last_admin = identity_client.patch(
|
||||
f"/api/auth/users/{admin_id}",
|
||||
json={"role": "admin", "enabled": False, "permissions": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert last_admin.status_code == 409
|
||||
|
||||
|
||||
def test_session_expiration_and_forgery_fail_closed(identity_client):
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import UserSession
|
||||
from backend.app.services.user_access import digest
|
||||
sign_in(identity_client, "reader")
|
||||
with Session(database.engine) as db:
|
||||
record = db.get(UserSession, digest(identity_client.cookies.get("appops_user_session")))
|
||||
record.expires_at = 1
|
||||
db.add(record)
|
||||
db.commit()
|
||||
assert identity_client.get("/api/apps").status_code == 401
|
||||
identity_client.cookies.set("appops_user_session", "forged")
|
||||
assert identity_client.get("/api/apps").status_code == 401
|
||||
|
||||
|
||||
def test_email_bootstrap_requires_verified_claim_and_is_pinned(identity_client):
|
||||
sign_in(identity_client, "username-only", email=None, preferred_username="jens@example.com", email_verified=True)
|
||||
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
|
||||
sign_in(identity_client, "unverified", email="jens@example.com")
|
||||
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
|
||||
|
||||
sign_in(identity_client, "verified", email="jens@example.com", email_verified=True)
|
||||
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "admin"
|
||||
sign_in(identity_client, "replacement", email="jens@example.com", email_verified=True)
|
||||
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
|
||||
|
||||
|
||||
def test_bootstrap_recovers_early_login_without_restoring_demoted_admin(identity_client, monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
user_id = sign_in(identity_client, "early")
|
||||
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:early", "authentik:owner"])
|
||||
sign_in(identity_client, "early")
|
||||
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "admin"
|
||||
sign_in(identity_client, "owner")
|
||||
response = identity_client.patch(f"/api/auth/users/{user_id}", json={"role": "read_only", "enabled": True, "permissions": []}, headers={"X-AppOps-Session-CSRF": "1"})
|
||||
assert response.status_code == 200
|
||||
sign_in(identity_client, "early")
|
||||
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
|
||||
|
||||
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"])
|
||||
sign_in(identity_client, "early", email="jens@example.com", email_verified=True)
|
||||
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
|
||||
|
||||
|
||||
def test_expired_disabled_and_forged_sessions_fail_closed(identity_client):
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import GatewayUser
|
||||
user_id = sign_in(identity_client, "reader")
|
||||
with Session(database.engine) as db:
|
||||
user = db.get(GatewayUser, user_id)
|
||||
user.enabled = False
|
||||
db.add(user)
|
||||
db.commit()
|
||||
assert identity_client.get("/api/apps").status_code == 401
|
||||
|
||||
|
||||
def test_proxy_headers_require_trusted_peer_and_fixed_uid(identity_client, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.user_access import current_user
|
||||
monkeypatch.setattr(settings, "operator_proxy_auth_enabled", True)
|
||||
monkeypatch.setattr(settings, "operator_proxy_trusted_networks", ["203.0.113.8/32"])
|
||||
headers = [(b"x-authentik-uid", b"owner"), (b"x-authentik-username", b"jens"), (b"x-authentik-email", b"jens@example.com")]
|
||||
def request(peer, supplied_headers):
|
||||
return Request({"type": "http", "method": "GET", "path": "/api/apps", "headers": supplied_headers, "client": (peer, 1234)})
|
||||
with pytest.raises(HTTPException) as rejected:
|
||||
current_user(request("198.18.10.20", headers))
|
||||
assert rejected.value.status_code == 401
|
||||
assert current_user(request("203.0.113.8", headers)).role == "admin"
|
||||
with pytest.raises(HTTPException) as rejected:
|
||||
current_user(request("203.0.113.8", headers[1:]))
|
||||
assert rejected.value.status_code == 401
|
||||
|
||||
|
||||
def test_custom_voice_and_approval_permissions_do_not_grant_execution(identity_client):
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
response = identity_client.patch(f"/api/auth/users/{reader_id}", json={"role": "custom", "enabled": True, "permissions": ["voice.use", "approvals.manage"]}, headers={"X-AppOps-Session-CSRF": "1"})
|
||||
assert response.status_code == 200
|
||||
sign_in(identity_client, "reader")
|
||||
for path in ["/api/voice/actions/container/plex/restart", "/api/approvals/approval-1/execute", "/api/connectors/portainer/bind", "/api/ai/rag/reindex"]:
|
||||
assert identity_client.post(path, json={}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 403
|
||||
|
||||
|
||||
def test_operational_users_cannot_change_enrollment_trust(identity_client):
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(f"/api/auth/users/{reader_id}", json={"role": "operator", "enabled": True}, headers=headers).status_code == 200
|
||||
sign_in(identity_client, "reader")
|
||||
assert identity_client.put("/api/enrollments/trust", json={}, headers=headers).status_code == 403
|
||||
sign_in(identity_client, "owner")
|
||||
# Administrator reaches schema validation; an operator is rejected first.
|
||||
assert identity_client.put("/api/enrollments/trust", json={}, headers=headers).status_code == 422
|
||||
Reference in new issue
Block a user