538 lines
17 KiB
Python
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()
|