Imported from SUNET/eduid-backend (
AGENTS.md). Install upstream withnpx skills add SUNET/eduid-backend. Copyright stays with the author.
AGENTS.md - AI Agent Guidelines for eduID Backend
This document provides guidelines for AI coding agents working in the eduID Backend repository.
Rule language in this document:
- Must: required for changes that are ready for review
- Should: the default approach unless there is a clear reason to do otherwise
- May: optional guidance
Unless noted otherwise, code snippets in this document are illustrative and may omit surrounding imports or setup.
Project Overview
eduID Backend is a Python 3.13 monorepo for Swedish federated identity management:
- Flask web apps (identity proofing, authentication, user management)
- FastAPI APIs (SCIM, MACC)
- Celery workers (background tasks)
- SATOSA plugins (SAML/OIDC proxy)
Key technologies: Flask, FastAPI, Pydantic v2, MongoDB, Neo4j, Redis, Celery, SAML2, WebAuthn/FIDO2.
Build/Lint/Test Commands
Running Tests
# Run all tests
make test
# Run a single test file
PYTHONPATH=src pytest -vvv src/eduid/webapp/freja_eid/tests/test_app.py
# Run a specific test class
PYTHONPATH=src pytest -vvv src/eduid/webapp/freja_eid/tests/test_app.py::FrejaEIDTests
# Run a specific test method
PYTHONPATH=src pytest -vvv src/eduid/webapp/freja_eid/tests/test_app.py::FrejaEIDTests::test_app_starts
# Run tests matching a pattern
PYTHONPATH=src pytest -vvv -k "test_verify" src/eduid/webapp/freja_eid/tests/
Tests require Docker services (MongoDB, Redis, Neo4j, SMTP). Tests auto-start containers as needed.
If stale test containers need to be cleaned up before rerunning tests, use make kill_tests.
Linting and Formatting
make lint # Run ruff linter
make reformat # Fix imports + format code + extended checks
Type Checking
make typecheck # Run both mypy and ty (required)
make typecheck_mypy # mypy only
make typecheck_ty # ty only
- mypy: Uses plugins
pydantic.mypy,marshmallow_dataclass.mypy - ty (experimental): New type checker being evaluated in beta
- Configuration in pyproject.toml
- Installed as part of the
testdependency group (make dev_sync_deps/make update_deps)
Tool configuration is centralized in pyproject.toml for Ruff, import sorting, mypy, pytest, coverage, and ty.
Dependency metadata is also centralized in pyproject.toml. The generated lockfiles in requirements/*.txt
remain the install artifacts used by CI and local setup, while requirements/*.in has been removed.
It will never be necessary to build a package out of this repo; pyproject.toml is used here as the
source of truth for dependency and tool metadata.
Dependency Updates
Dependency changes should be made in pyproject.toml, not in files under requirements/.
If the project must use an exact version or a custom local build, pin that exact requirement directly in
pyproject.toml.
Dependency metadata must stay aligned with the repository Python baseline declared in pyproject.toml. When the minimum supported Python version already provides a stdlib feature, should remove obsolete backports, compatibility-only markers, and legacy conditional dependencies unless the code still imports the third-party package name and the change is part of the same update.
make update_deps
This regenerates the compiled lockfiles in requirements/*.txt from pyproject.toml using the
profiles and groups defined there.
Code Style Guidelines
Import Ordering
Imports are organized in groups separated by blank lines:
from __future__ import annotations(if needed)- Standard library imports (alphabetically)
- Third-party imports (alphabetically)
- Local project imports (alphabetically)
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Self
from pydantic import BaseModel, Field
from eduid.userdb.user import User
from eduid.webapp.common.api.messages import TranslatableMsg
Type Annotations
Must use modern Python 3.10+ type syntax:
def get_user(identifier: str) -> User | None: # Union with |
def require_user[T](f: Callable[..., T]) -> Callable[..., T]: # Generic params
def process(items: Sequence[str]) -> Mapping[str, Any]: # collections.abc types
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Classes | PascalCase | UserPreferences, FrejaEIDApp |
| Functions/methods | snake_case | get_user, verify_identity |
| Private members | Leading underscore | _parse_data, _internal_state |
| Constants | SCREAMING_SNAKE_CASE | EPPN_LENGTH = 11 |
| Module variables | snake_case | logger = logging.getLogger(__name__) |
Pydantic Models
class UserConfig(BaseModel):
name: str = Field(alias="displayName")
email: str | None = Field(default=None)
model_config = ConfigDict(validate_by_name=True, validate_assignment=True, extra="forbid")
Error Handling
Should use hierarchical custom exceptions:
class EduIDDBError(Exception):
def __init__(self, reason: object) -> None:
Exception.__init__(self)
self.reason = reason
class UserDoesNotExist(EduIDDBError):
"""Requested user could not be found."""
Logging
logger = logging.getLogger(__name__)
logger.debug(f"Processing user: {user.eppn}")
current_app.logger.exception("Unexpected error") # In Flask views
Docstrings (Sphinx/reST style)
def authenticate(self, user_id: str, factors: Sequence[VCCSFactor]) -> bool:
"""
Authenticate a user with the provided factors.
:param user_id: Persistent user identifier
:param factors: Authentication factors to verify
:returns: True if authentication succeeds
"""
Enums
from enum import StrEnum, unique
@unique
class IdentityType(StrEnum):
NIN = "nin"
EIDAS = "eidas"
FREJA = "freja"
Flask Views
@blueprint.route("/verify", methods=["POST"])
@UnmarshalWith(RequestSchema)
@MarshalWith(ResponseSchema)
@require_user
def verify(user: User, method: str, frontend_action: str, frontend_state: str | None = None) -> FluxData:
...
return success_response(payload={"status": "ok"})
Testing Patterns
Tests are located alongside source code in tests/ subdirectories:
src/eduid/webapp/freja_eid/
├── app.py
├── views.py
└── tests/
└── test_app.py
Base Test Classes
Each webapp has a specific test base class. IdP tests should use IdPAPITests:
from eduid.webapp.idp.tests.test_api import IdPAPITests
class TestMyFeature(IdPAPITests):
def update_config(self, config: dict[str, Any]) -> dict[str, Any]:
return super().update_config(config)
def test_something(self) -> None:
user = self.app.userdb.lookup_user(self.test_user.eppn)
# ... test logic
Other webapp tests should use EduidAPITestCase[AppType]:
class MyAppTests(EduidAPITestCase[MyApp]):
def load_app(self, config: dict[str, Any]) -> MyApp:
return my_app_init_app(name="testing", config=config)
def update_config(self, config: dict[str, Any]) -> dict[str, Any]:
config["my_setting"] = "test_value"
return config
Test Helper Methods (IdP)
The IdPAPITests base class provides helper methods for common test scenarios:
# Add a security key (FIDO/WebAuthn credential) to test user
self.add_test_user_security_key(
user=None, # defaults to self.test_user
credential_id="webauthn_keyhandle",
is_verified=False,
mfa_approved=False,
always_use_security_key=True,
)
# Add external MFA credential (SwedenConnect, eIDAS, BankID, Freja)
from eduid.userdb.credentials.external import TrustFramework
cred = self.add_test_user_external_mfa_cred(
user=None, # defaults to self.test_user
trust_framework=TrustFramework.SWECONN, # SWECONN, EIDAS, BANKID, FREJA
trust_level="loa3", # e.g., "loa3", "eidas-nf-high", "uncertified-loa3", "freja-loa3"
)
# Add Terms of Use acceptance
self.add_test_user_tou(eppn=None, version=None)
# Add mail address
self.add_test_user_mail_address(mail_address)
# Get user from IdP userdb
user = self.app.userdb.lookup_user(self.test_user.eppn)
Mocking Patterns
Tests must use pytest-mock fixtures instead of direct unittest.mock imports. Function-scoped tests should use mocker: MockerFixture,
and class-scoped setup in EduidAPITestCase subclasses should use class_mocker. Tests should use
mocker.patch, mocker.patch.object, mocker.MagicMock, and mocker.AsyncMock instead of importing
patch, MagicMock, or AsyncMock directly.
from pytest_mock import MockerFixture
def test_get_all_navet_data(self, mocker: MockerFixture) -> None:
mock_get_all_navet_data = mocker.patch("eduid.workers.msg.tasks.get_all_navet_data.apply_async")
self.msg_relay.get_all_navet_data("190000000000")
mock_get_all_navet_data.assert_called_once_with(kwargs={"nin": "190000000000"})
If you need a typed test double for a complex object, create it through mocker and use cast() only at the
boundary:
from typing import cast
from pytest_mock import MockerFixture
def _make_ticket(
mocker: MockerFixture, credentials_used: Mapping[ElementKey, AuthnData] | None = None
) -> LoginContext:
if credentials_used is None:
credentials_used = {}
ticket = cast(LoginContext, mocker.MagicMock(spec=LoginContext))
ticket.pending_request = mocker.MagicMock()
ticket.pending_request.credentials_used = credentials_used
return cast(LoginContext, ticket)
Should use real objects instead of mocks when feasible.
# Use real AuthnData rather than MagicMock when feasible
from eduid.webapp.idp.idp_authn import AuthnData
from eduid.common.misc.timeutil import utc_now
authn_data = AuthnData(cred_id=credential.key, timestamp=utc_now())
Post-Edit Checklist
Must run these commands after completing test changes:
make reformat # Fix imports and formatting
make lint # Verify code standard
make typecheck # Verify type correctness (mypy and ty)
Commit Message Convention
Must create signed commits.
Must never commit with --no-gpg-sign.
If commit signing fails, fix the signing issue and try again with signing enabled rather than falling back to an unsigned commit.
Should use Conventional Commits for commit messages:
feat(webapp): add new identity verification flow
fix(userdb): handle missing email gracefully
refactor(scimapi): simplify group membership logic
test(workers): add coverage for edge cases
docs: update API documentation
chore: update dependencies
Project Structure
src/eduid/
├── common/ # Shared utilities, config, models
├── graphdb/ # Neo4j integration
├── maccapi/ # MACC API (FastAPI)
├── queue/ # Celery message queue
├── satosa/ # SATOSA proxy plugins
├── scimapi/ # SCIM 2.0 API (FastAPI)
├── userdb/ # User database models
├── vccs/ # Credential validation
├── webapp/ # Flask web applications
└── workers/ # Background workers
Ruff Configuration
- Configuration lives in pyproject.toml
- Line length: 120 characters
- Target: Python 3.13
- Key rules: ANN, ASYNC, E, F, I (Ruff import sorting, replacing standalone isort), PERF, UP (pyupgrade)
- Magic numbers allowed in test files (PLR2004 ignored)
CI/CD
GitHub Actions runs on push/PR:
- unittests:
make testwith Docker services - typecheck:
make typecheck(mypy and ty) - lint: ruff linting
All must pass for merge.