CoursePython · Application Architecture and Production Readiness · part 76 of 79
Part 76 · Application Architecture and Production Readiness

Lesson 6: Security Review

4 min read·9 Sept 2026

Input validation and unsafe deserialization

Everything crossing a boundary is validated, which by now is established practice rather than new material. The production checklist is that no boundary was missed.

The boundaries in this system. HTTP request bodies, validated by Pydantic in the route models. Uploaded files, which need type, size, and content checks from Module 4. Model outputs, validated in Module 6. Tool arguments, validated before dispatch in Module 6. Retrieved chunks, which are content rather than instructions. Webhook payloads arriving from outside, which need signature verification. Environment configuration, validated in Lesson 2. And database rows, which are trusted only if nothing untrusted wrote them.

Bound every input.

python
class ExtractionRequest(BaseModel):
    model_config = ConfigDict(extra="forbid", str_max_length=10_000)

    document_id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
    max_chunks: int = Field(default=8, ge=1, le=50)

extra="forbid" rejects unexpected fields rather than ignoring them, which turns a client bug into a clear error and blocks a class of parameter-smuggling attempt. The pattern on document_id matters because that value may reach a path, a query filter, or a cache key.

Unsafe deserialization. Module 4 covered why loading a pickle executes code. The production statement is broader: never deserialize untrusted data with a format that can construct arbitrary objects. That means no pickle, no yaml.load without the safe loader, and caution with any library that reconstructs classes from a payload. Use JSON, and validate it into a typed model.

Note that this includes model files. Some machine learning formats are pickle-based, so a model downloaded from a public hub is untrusted input by the same rule.

Dependency auditing

Your dependency tree is code you did not write and are responsible for.

bash
uv run pip-audit                         # known vulnerabilities in installed packages
uv lock --upgrade                        # refresh within declared ranges
uv tree                                  # what is actually installed, and why

[VOLATILE: auditing tooling changes. Verify what your package manager offers directly before adding a separate tool.]

Four practices worth having.

Run an audit in CI, on a schedule rather than only on commits, since a vulnerability is disclosed on its own timetable rather than yours.

Keep the lockfile authoritative, which Module 1 established. Hash verification is what makes a supply-chain substitution fail rather than install.

Review new dependencies before adding them. A package with one maintainer, no recent releases, and few users is a risk you are adopting, and the transitive tree matters as much as the direct addition.

Pin the base image by digest rather than by tag, since a tag can move.

Have a stated response. A critical vulnerability in a direct dependency needs a same-week upgrade. One in a transitive dependency you do not exercise may be lower priority. Deciding this before an alert arrives is what makes the alert actionable.

Authorization on tool-backed endpoints

This is where Module 6's security material meets the API layer, and it is the highest-consequence section in this lesson.

Authentication asks who you are. Authorization asks what you may do. An endpoint that authenticates and does not authorize lets any valid user do anything.

python
async def execute_tool_for_user(
    tool_name: str,
    arguments: dict,
    *,
    user: AuthenticatedUser,
    registry: ToolRegistry,
) -> ToolResult:
    """Enforce the user's permissions, not just the model's request."""
    tool = registry.get(tool_name)
    if tool is None:
        return ToolResult.error(f"unknown tool {tool_name!r}")

    if not user.may_use_tool(tool_name):
        logger.warning("tool_permission_denied",
                       extra={"tool": tool_name, "user_id": user.id})
        return ToolResult.error(f"you do not have permission to use {tool_name}")

    args = tool.args_model.model_validate(arguments)
    scoped = apply_user_scope(args, user)          # narrow to what this user may touch
    return await run_tool(tool, scoped)

The critical principle: the model's permissions must never exceed the user's. An agent acting on behalf of a user must be able to do only what that user could do directly. Otherwise the agent is a privilege escalation path, and Module 6's confused deputy problem becomes exploitable by anyone who can get text into the context.

Scope at the data level, not only the operation level. Permission to search is not permission to search everything, so the user's scope must be applied as a filter inside the query. Module 9 made this concrete: post-filtering by permission means the search already saw documents the user cannot access, and the result count leaks their existence.

Gate irreversible and outbound actions, which is Module 6's human-in-the-loop requirement expressed as an API concern: those endpoints need explicit confirmation rather than proceeding on a model's say-so.

Log every tool execution with the user, the tool, and the arguments. This is the audit trail that makes an incident reconstructable, and it is subject to Module 11's rule about logging identifiers rather than content.

A pre-deployment checklist for anything tool-backed. Every endpoint authenticates. Every endpoint authorizes, not just authenticates. Tool permissions derive from user permissions. Data access is scoped inside the query. Irreversible actions require confirmation. Every execution is logged. Untrusted content cannot reach a privileged tool without passing a boundary you can name.