Skip to content

feat(ogc): add authenticated internal OGC mount (BDMS-985 A11)#783

Open
ksmuczynski wants to merge 5 commits into
kas-bdms-971-apply-public-filter-to-all-OGC-viewsfrom
kas-bdms-985-internal-ogc-mount-A11
Open

feat(ogc): add authenticated internal OGC mount (BDMS-985 A11)#783
ksmuczynski wants to merge 5 commits into
kas-bdms-971-apply-public-filter-to-all-OGC-viewsfrom
kas-bdms-985-internal-ogc-mount-A11

Conversation

@ksmuczynski

Copy link
Copy Markdown
Contributor

Summary

Stands up a second, authenticated pygeoapi mount at /ogcapi-internal,
mirroring all 22 ogc_* relations A1 restricts to public records as
unfiltered ogc_internal_* counterparts, so staff who need to see
private/draft records (pre-publication QA, internal review) can, without
touching the public catalog.

Why

Companion to A1 (restricts /ogcapi to release_status = 'public').
Locking the public mount down means internal users still needs unfiltered access
for the records A1 now hides. This stands up that access as its own
authenticated mount rather than punching a hole back into the public one.

Changes

  • Internal OGC auth gate: InternalOGCAuthMiddleware is a plain ASGI
    middleware (not BaseHTTPMiddleware, which buffers the full response
    body and breaks client-disconnect propagation. This mount serves
    paginated GeoJSON up to max_items: 10000). Splits 401 (no/invalid
    token) from 403 (valid token, wrong group) per the acceptance criteria,
    rather than collapsing both into 401 like the rest of the app does today.
  • Second pygeoapi mount: mount_pygeoapi_internal() gets its own
    guard flag and runtime dir, a startup check that the two mount paths
    actually differ (Starlette silently routes to whichever registers
    first otherwise), and an equality check on the two configs'
    server.encoding/server.gzip settings, since pygeoapi mutates
    process-wide globals from those at construction time.
  • Unfiltered internal views: new migration creating ogc_internal_*
    counterparts to all 22 ogc_* relations A1 touches, using the same
    dependency-ordering (ogc_internal_actively_monitored_wells before/after
    ogc_internal_water_well_summary) and unique-index treatment as the
    public side.
  • Analyte-mapping drift guard: an AST-based test keeping the two
    chemistry views' CASE-mapping logic byte-identical across both
    migrations, since this repo's migrations can't import from each other.
  • Behave coverage: the 6 A11 scenarios already drafted into
    ogc-cleanup-sprint1.feature are now tagged @production and
    implemented: auth (401/403/200), internal record exposure, DB relation
    separation, and public-surface non-interference.

Verification

All against local ocotilloapi_test, never staging/prod.

  • uv run pytest tests/test_migration_view_parity.py tests/test_pygeoapi_mount.py -v — 10 passed.
  • uv run pytest (full suite): 724 passed, 81 skipped, 6 xpassed, 0 failed.
  • alembic upgrade head / downgrade -1 / upgrade head: clean in both
    directions; confirmed ogc_water_wells and ogc_internal_water_wells
    coexist as 2 distinct relations, and downgrade fully drops the internal
    set.
  • DROP_AND_REBUILD_DB=1 AUTHENTIK_DISABLE_AUTHENTICATION=1 uv run behave tests/features/ogc-cleanup-sprint1.feature --tags=@A11:
    6/6 scenarios passed (32 other scenarios sharing this feature file
    correctly skipped, untouched).

Notes

  • The migration commit touches f4a5b6c7d8e9 (A1's migration file),
    adding a one-line cross-reference comment pointing at this migration
    and the new parity test, not a functional change. Flagging since it's
    the only diff here to a file introduced by a different ticket/PR.
  • /ogcapi-internal collections share the same IDs as /ogcapi (e.g.
    both expose water_wells); only the backing table differs
    (ogc_water_wells vs ogc_internal_water_wells), following the plan's
    table_prefix-only parametrization. Each internal collection's
    self-link still resolves to /ogcapi-internal, which is what the
    Behave "collections not available on the public endpoint" scenario
    actually checks, rather than comparing id sets. This was a judgment
    call, not an explicit instruction. Happy to switch to a distinct id
    namespace (e.g. internal_water_wells) if you'd rather have that.

pygeoapi is mounted via a raw Starlette Mount, so FastAPI's Depends() machinery never runs for it -- auth has to happen at the ASGI layer, in front of the mount, not as a route dependency.

Added TokenInvalid and decode_token_payload() alongside the existing _get_token_payload so the same JWKS/jwt.decode logic can be reused from code with no FastAPI exception handler watching (raising HTTPException there would just be an unhandled exception, not a response).

InternalOGCAuthMiddleware is a plain ASGI middleware class rather than BaseHTTPMiddleware (used elsewhere in this app for request logging and lazy admin init): BaseHTTPMiddleware buffers the full response body and breaks client-disconnect propagation, which matters here since this mount serves paginated GeoJSON up to max_items: 10000.

Splits 401 (no/invalid token) from 403 (valid token, wrong group) per the acceptance criteria, rather than collapsing both into 401 like every other auth path in this app does today -- the underlying check is identical either way, so the split is a one-line branch, not meaningfully more code to maintain.
Generalized _mount_path()/_pygeoapi_dir()/_write_config() to take an env var and default rather than hardcoding the public mount's values, so the internal mount gets the same traversal/character safety and sensitive-file handling for free instead of a copy-pasted second implementation.

mount_pygeoapi_internal() gets its own guard flag and runtime dir so it can't silently no-op against the public mount's state, and a startup check that the two configured mount paths actually differ -- Starlette doesn't error on duplicate Mounts, it just routes to whichever registered first, which would leave the internal mount silently unreachable rather than failing loudly.

Also added _assert_server_settings_match(): pygeoapi.api.API mutates process-wide globals (CHARSET, FORMAT_TYPES) during __init__, so whichever of the two mounts is built last wins for both. Inert today since both configs agree on encoding/gzip, but this fails startup loudly instead of letting a future divergence between the two configs silently corrupt responses on whichever mount lost the race.
Mirrors all 22 ogc_* relations f4a5b6c7d8e9 filters, as ogc_internal_* counterparts with no release_status predicate, so /ogcapi-internal can serve private/draft records to authenticated staff. Reuses f4a5b6c7d8e9's parametrized builder functions (public_only kept in the signature and always passed False) for structural parity rather than a differently-shaped rewrite, since that's what the drift-detection test in the next commit diffs against.

This repo keeps migrations self-contained with no cross-migration imports, so the two chemistry views' analyte-mapping CASE blocks are duplicated here rather than shared via a helper module -- a shared module would mean a future edit to one ticket's migration silently changes what the other replays from scratch. Added a one-line cross-reference comment in both files pointing at each other and the parity test.

ogc_internal_locations has no release_status predicate at all (unlike ogc_locations, which is always public-only even on its own downgrade path) since it never existed in any filtered form before this migration. downgrade() simply drops all 22 relations rather than restoring a prior state, since none of them existed before this migration.
The major/minor chemistry CASE blocks now exist in two files (f4a5b6c7d8e9 and 2d3c3a268652) because migrations can't import from each other. Without a check, a future analyte-mapping fix applied to the public view (e.g. reported against a public-facing bug) has no reason to also touch the internal view sitting in a different file from a different ticket -- silently leaving internal staff looking at the stale mapping, which inverts who has the more reliable data.

Compares AST source segments rather than raw text/regex so the comparison survives incidental formatting differences and only fails on a genuine logic change. Normalizes the one expected difference (the view name embedded in the CREATE statement) before comparing.
Tags the 6 A11 scenarios A1 already wrote into this shared feature file with @production, appended to their existing tag lines rather than at the feature level -- this file has no feature-level tag since it's shared across ~10 other tickets' scenarios, and tagging at that level would pull in every other ticket's undefined steps.

The 401/403/200 auth scenarios neutralize the ambient AUTHENTIK_DISABLE_AUTHENTICATION dev-bypass for their own duration only (restored via context.add_cleanup) and patch core.permissions.decode_token_payload directly, since no real Authentik server is available in CI to issue a genuine JWT. Existing auth-testing infrastructure in this repo only reaches app.dependency_overrides, which has zero effect on ASGI middleware -- none of it exercises InternalOGCAuthMiddleware for free.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant