Files
ewoooc/tests/test_auth_identity_governance.py

538 lines
17 KiB
Python

from datetime import datetime, timedelta
from flask import Flask
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from werkzeug.security import generate_password_hash
import auth
import services.permission_service as permission_service_module
from database.permission_models import UserPermission
from database.user_models import LoginHistory, User
from services.auth_identity_service import (
build_auth_identity_runtime_readback,
required_roles_for_request,
resolve_auth_identity_mode,
resolve_client_ip,
)
from services.user_service import UserService
from services.permission_service import PermissionService
@pytest.fixture
def identity_store():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
User.__table__.create(engine)
LoginHistory.__table__.create(engine)
UserPermission.__table__.create(engine)
return sessionmaker(bind=engine)
@pytest.fixture
def identity_user(identity_store):
db_session = identity_store()
now = datetime.now()
user = User(
username="operator-admin",
password_hash=generate_password_hash("DatabasePass9!", method="pbkdf2:sha256"),
role="admin",
display_name="Operator Admin",
is_active=True,
password_changed_at=now,
created_at=now,
updated_at=now,
)
db_session.add(user)
db_session.commit()
user_id = user.id
db_session.close()
return user_id
@pytest.fixture
def identity_app(monkeypatch, identity_store, identity_user):
monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "hybrid")
monkeypatch.setenv("MOMO_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128")
monkeypatch.setattr(auth, "LOGIN_PASSWORD", "LegacyPass9!")
monkeypatch.setattr(auth, "get_identity_session", identity_store)
app = Flask(__name__, template_folder=str(auth.os.path.join(auth.os.path.dirname(__file__), "..", "templates")))
app.config.update(
TESTING=True,
SECRET_KEY="identity-test-secret",
SESSION_COOKIE_SECURE=False,
)
app.jinja_env.globals["csrf_token"] = lambda: "test-csrf"
app.add_url_rule(
"/",
endpoint="dashboard.index",
view_func=lambda: "dashboard",
)
@app.route("/protected")
@auth.login_required
def protected():
return "protected"
auth.init_auth_routes(app)
return app
def test_proxy_headers_are_ignored_from_untrusted_peer():
assert resolve_client_ip(
"203.0.113.9",
"198.51.100.7",
trusted_proxy_cidrs="127.0.0.1/32",
) == "203.0.113.9"
assert resolve_client_ip(
"127.0.0.1",
"198.51.100.7, 127.0.0.1",
trusted_proxy_cidrs="127.0.0.1/32",
) == "198.51.100.7"
def test_auth_mode_and_role_matrix_fail_closed():
assert resolve_auth_identity_mode("auto") == "auto"
with pytest.raises(ValueError):
resolve_auth_identity_mode("invalid")
assert required_roles_for_request("GET", "vendor.list", "vendor") == (
"admin",
"manager",
"user",
)
assert required_roles_for_request("POST", "vendor.update", "vendor") == (
"admin",
"manager",
)
assert required_roles_for_request("GET", "cicd.status", "cicd") == ("admin",)
def test_lockout_is_durable_across_database_sessions(identity_store):
for _ in range(5):
db_session = identity_store()
assert UserService(db_session).record_login(
None,
"operator-admin",
"198.51.100.20",
"pytest",
LoginHistory.STATUS_FAILED,
"invalid_credentials",
) is not None
db_session.close()
db_session = identity_store()
service = UserService(db_session)
locked, remaining, count = service.get_lockout_status(
"operator-admin",
"198.51.100.20",
max_attempts=5,
reset_seconds=1800,
lockout_seconds=300,
)
assert locked is True
assert remaining > 0
assert count == 5
service.record_login(
None,
"operator-admin",
"198.51.100.20",
"pytest",
LoginHistory.STATUS_SUCCESS,
"auth_source=database",
)
locked, remaining, count = service.get_lockout_status(
"operator-admin",
"198.51.100.20",
)
assert (locked, remaining, count) == (False, 0, 0)
db_session.close()
def test_database_identity_login_writes_audit_and_versioned_session(
identity_app,
identity_store,
identity_user,
):
client = identity_app.test_client()
response = client.post(
"/login",
data={"username": "operator-admin", "password": "DatabasePass9!"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/")
with client.session_transaction() as current_session:
assert current_session["user_id"] == identity_user
assert current_session["role"] == "admin"
assert current_session["auth_source"] == "database"
assert current_session["identity_version"]
db_session = identity_store()
event = db_session.query(LoginHistory).order_by(LoginHistory.id.desc()).first()
assert event.status == LoginHistory.STATUS_SUCCESS
assert event.failure_reason == "auth_source=database"
db_session.close()
def test_identity_change_revokes_existing_session(
identity_app,
identity_store,
identity_user,
):
client = identity_app.test_client()
assert client.post(
"/login",
data={"username": "operator-admin", "password": "DatabasePass9!"},
).status_code == 302
db_session = identity_store()
user = db_session.query(User).filter(User.id == identity_user).one()
user.updated_at = datetime.now() + timedelta(seconds=1)
db_session.commit()
db_session.close()
response = client.get("/protected")
assert response.status_code == 302
assert response.headers["Location"].endswith("/login")
with client.session_transaction() as current_session:
assert "logged_in" not in current_session
db_session = identity_store()
statuses = [row.status for row in db_session.query(LoginHistory).all()]
assert LoginHistory.STATUS_REVOKED in statuses
db_session.close()
def test_legacy_hybrid_login_audits_real_peer_not_spoofed_header(
identity_app,
identity_store,
):
client = identity_app.test_client()
response = client.post(
"/login",
data={"username": "", "password": "LegacyPass9!"},
headers={"X-Forwarded-For": "198.51.100.88"},
environ_base={"REMOTE_ADDR": "203.0.113.44"},
)
assert response.status_code == 302
with client.session_transaction() as current_session:
assert current_session["auth_source"] == "legacy_shared_password"
assert current_session["client_ip"] == "203.0.113.44"
db_session = identity_store()
event = db_session.query(LoginHistory).order_by(LoginHistory.id.desc()).first()
assert event.ip_address == "203.0.113.44"
db_session.close()
def test_database_mode_refuses_legacy_authority(
monkeypatch,
identity_app,
):
monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "database")
client = identity_app.test_client()
response = client.post(
"/login",
data={"username": "", "password": "LegacyPass9!"},
)
assert response.status_code == 401
with client.session_transaction() as current_session:
assert "logged_in" not in current_session
def test_auto_mode_retires_legacy_session_after_durable_admin_canary(
monkeypatch,
identity_app,
identity_store,
identity_user,
):
monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "auto")
client = identity_app.test_client()
assert client.post(
"/login",
data={"username": "", "password": "LegacyPass9!"},
).status_code == 302
db_session = identity_store()
service = UserService(db_session)
for _ in range(2):
assert service.record_login(
identity_user,
"operator-admin",
"127.0.0.1",
"pytest",
LoginHistory.STATUS_SUCCESS,
"auth_source=database",
) is not None
assert service.get_auth_auto_cutover_state()["ready"] is True
db_session.close()
response = client.get("/protected")
assert response.status_code == 302
assert response.headers["Location"].endswith("/login")
with client.session_transaction() as current_session:
assert "logged_in" not in current_session
def test_unversioned_cookie_is_rejected_outside_test_mode(monkeypatch, identity_store):
monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "hybrid")
monkeypatch.setattr(auth, "get_identity_session", identity_store)
app = Flask(__name__)
app.config.update(SECRET_KEY="production-shape-secret", TESTING=False)
@app.route("/login", endpoint="login")
def login_stub():
return "login"
@app.route("/protected-production-shape")
@auth.login_required
def protected_production_shape():
return "protected"
client = app.test_client()
with client.session_transaction() as current_session:
current_session["logged_in"] = True
response = client.get("/protected-production-shape")
assert response.status_code == 302
assert response.headers["Location"].endswith("/login")
with client.session_transaction() as current_session:
assert "logged_in" not in current_session
def test_runtime_readback_separates_canary_from_closed(identity_store, identity_user):
db_session = identity_store()
report = build_auth_identity_runtime_readback(db_session, mode="hybrid")
assert report["status"] == "canary_ready"
assert report["canary_ready"] is True
assert report["runtime_closed"] is False
assert report["inventory"]["active_admin_count"] == 1
assert report["checks"]["legacy_shared_password_retired"] is False
closed = build_auth_identity_runtime_readback(db_session, mode="database")
assert closed["status"] == "completed"
assert closed["runtime_closed"] is True
assert closed["checks"]["legacy_shared_password_retired"] is True
db_session.close()
def test_auto_runtime_readback_closes_after_admin_success_threshold(
identity_store,
identity_user,
):
db_session = identity_store()
initial = build_auth_identity_runtime_readback(db_session, mode="auto")
assert initial["configured_auth_identity_mode"] == "auto"
assert initial["auth_identity_mode"] == "hybrid"
assert initial["runtime_closed"] is False
service = UserService(db_session)
for _ in range(2):
service.record_login(
identity_user,
"operator-admin",
"127.0.0.1",
"pytest",
LoginHistory.STATUS_SUCCESS,
"auth_source=database",
)
promoted = build_auth_identity_runtime_readback(db_session, mode="auto")
assert promoted["configured_auth_identity_mode"] == "auto"
assert promoted["auth_identity_mode"] == "database"
assert promoted["auto_cutover"]["manual_review_required"] is False
assert promoted["auto_cutover"]["database_admin_successes"] == 2
assert promoted["runtime_closed"] is True
db_session.close()
def test_identity_mutations_write_no_secret_audits(
identity_store,
identity_user,
):
audit_context = {
"actor_id": identity_user,
"ip_address": "198.51.100.40",
"user_agent": "pytest-identity-audit",
}
db_session = identity_store()
service = UserService(db_session)
user, error = service.create_user(
"audit-target",
"CreatePass9!",
role="admin",
created_by=identity_user,
audit_context=audit_context,
)
assert error is None
success, error = service.update_user(
user.id,
audit_context=audit_context,
role="user",
display_name="Audit Target",
)
assert (success, error) == (True, None)
success, error = service.change_password(
user.id,
"CreatePass9!",
"ChangedPass8!",
audit_context=audit_context,
)
assert (success, error) == (True, None)
success, error = service.reset_password(
user.id,
"ResetPass7!",
admin_id=identity_user,
audit_context=audit_context,
)
assert (success, error) == (True, None)
success, error = service.delete_user(user.id, audit_context=audit_context)
assert (success, error) == (True, None)
events = db_session.query(LoginHistory).filter(
LoginHistory.user_id == user.id,
).order_by(LoginHistory.id).all()
assert [event.status for event in events] == [
LoginHistory.STATUS_USER_CREATED,
LoginHistory.STATUS_IDENTITY_CHANGED,
LoginHistory.STATUS_PASSWORD_CHANGED,
LoginHistory.STATUS_PASSWORD_CHANGED,
LoginHistory.STATUS_USER_DISABLED,
]
serialized = " ".join(
f"{event.failure_reason or ''} {event.user_agent or ''}"
for event in events
)
assert "CreatePass9!" not in serialized
assert "ChangedPass8!" not in serialized
assert "ResetPass7!" not in serialized
assert all(event.ip_address == "198.51.100.40" for event in events)
assert service.get_identity_inventory()["identity_mutation_events"] == 5
db_session.close()
def test_identity_mutation_and_audit_roll_back_together(
monkeypatch,
identity_store,
identity_user,
):
db_session = identity_store()
def fail_commit():
raise RuntimeError("forced commit failure")
monkeypatch.setattr(db_session, "commit", fail_commit)
user, error = UserService(db_session).create_user(
"rollback-target",
"RollbackPass9!",
role="admin",
created_by=identity_user,
audit_context={"actor_id": identity_user},
)
assert user is None
assert "forced commit failure" in error
db_session.close()
verification_session = identity_store()
assert verification_session.query(User).filter_by(username="rollback-target").count() == 0
assert verification_session.query(LoginHistory).filter_by(
username_attempted="rollback-target",
).count() == 0
verification_session.close()
def test_permission_mutation_and_audit_share_one_transaction(
monkeypatch,
identity_store,
identity_user,
):
seed_session = identity_store()
target = User(
username="permission-target",
password_hash=generate_password_hash("PermissionPass9!", method="pbkdf2:sha256"),
role="user",
display_name="Permission Target",
is_active=True,
password_changed_at=datetime.now(),
created_at=datetime.now(),
)
seed_session.add(target)
seed_session.commit()
target_id = target.id
seed_session.close()
monkeypatch.setattr(permission_service_module, "get_session", identity_store)
success, message = PermissionService.set_user_permissions(
target_id,
["dashboard.view", "dashboard.view"],
granted_by=identity_user,
audit_context={
"actor_id": identity_user,
"ip_address": "198.51.100.41",
"user_agent": "pytest-permission-audit",
},
)
assert success is True
assert "1 項權限" in message
verification_session = identity_store()
assert verification_session.query(UserPermission).filter_by(user_id=target_id).count() == 1
event = verification_session.query(LoginHistory).filter_by(
user_id=target_id,
status=LoginHistory.STATUS_PERMISSION_CHANGED,
).one()
assert event.failure_reason == f"actor_id={identity_user};change=replace;count=1"
assert event.ip_address == "198.51.100.41"
assert event.user_agent == "pytest-permission-audit"
verification_session.close()
def test_permission_mutation_and_audit_roll_back_on_commit_failure(
monkeypatch,
identity_store,
identity_user,
):
seed_session = identity_store()
target = User(
username="permission-rollback",
password_hash=generate_password_hash("PermissionPass9!", method="pbkdf2:sha256"),
role="user",
display_name="Permission Rollback",
is_active=True,
password_changed_at=datetime.now(),
created_at=datetime.now(),
)
seed_session.add(target)
seed_session.commit()
target_id = target.id
seed_session.close()
failing_session = identity_store()
def fail_commit():
raise RuntimeError("forced permission commit failure")
monkeypatch.setattr(failing_session, "commit", fail_commit)
monkeypatch.setattr(permission_service_module, "get_session", lambda: failing_session)
success, message = PermissionService.set_user_permissions(
target_id,
["dashboard.view"],
granted_by=identity_user,
audit_context={"actor_id": identity_user},
)
assert success is False
assert "forced permission commit failure" in message
verification_session = identity_store()
assert verification_session.query(UserPermission).filter_by(user_id=target_id).count() == 0
assert verification_session.query(LoginHistory).filter_by(
user_id=target_id,
status=LoginHistory.STATUS_PERMISSION_CHANGED,
).count() == 0
verification_session.close()