Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions docs/ADR-0003-deprecation-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# ADR-0003 — Local-token Auth: Header-First, Query-String Deprecated

| Field | Value |
|------------|----------------------------------------------------------------|
| Status | Accepted, in deprecation window |
| Date | 2026-07-25 |
| Owner | sapsf platform |
| Closes | audit-finding R-006 (legacy `?token=` channel on local Flask) |
| Removal | 2026-10-25 |

## Context

The sapsf portfolio's local Flask surfaces historically accepted the
`?token=...` query-string channel as a fallback when an authentication header
was not provided. This was once useful because shell scripts had trouble
passing headers and curl users naturally reach for `--data-urlencode`.

With the move to 12-factor share-nothing deploys and the proliferation of
cross-tool log scrapers, query-string tokens are now a probe target:

* They appear in reverse-proxy access logs and process listings.
* They are cached in browser history and corporate proxy caches.
* They survive into the URL bar and leak via shared screenshots.

## Decision

Move HTTP-token-gated sapsf local Flask surfaces to header-only. New
write-path surfaces use the shared helper
`sapsf_shared.flask_base.require_local_token` - header-only,
with 403 responses for client authentication failures. A missing server-side
token configuration returns 503. For legacy surfaces still accepting `?token=` (notably
`sf-object-sync/web_ui/app.py`) we add a single `app.logger.warning`
per request so operators see probe traffic in observability.

Local credential-management Flask surfaces (encrypted-at-rest secrets,
session CSRF on every POST) are out of scope: they have no HTTP token
channel to deprecate. See "Applicability by surface type" below.

A regression pin at `sf-object-sync/tests/test_web_ui_auth.py` freezes
the current deprecation behaviour: header works, query works but warns,
wrong/missing returns 401.

## Deprecation timeline

| Phase | Date | Surface state |
|----------------------|------------|------------------------------------------------------------------------------------------------|
| Now | 2026-07-25 | Header primary. `?token=` accepted, ONE warning log per request. Tests pin behaviour. |
| Removal candidate | 2026-10-25 | `?token=` returns 401 with no warning. Test's fallback case flips to a permanent 401 pin. |
| Post-removal cleanup | 2026-11-25 | Query-string branch removed from `_require_token`; only `X-Auth-Token` and the header fallback. |

If a critical operator cannot migrate by 2026-10-25, file the blocker at
`_shared/docs/DECISIONS_pending_owner_approval.md` with the specific
caller URL and rolling-impact estimate.

## Operator action

By **2026-10-25**, every script and pocket-reference doc that hits an
HTTP-token-gated local sapsf Flask surface (see the table below) must
switch from `?token=...` to the surface's header: `X-Auth-Token` for
sf-object-sync and `X-Report-Token` for report endpoints. After that
date, **sf-object-sync** returns 401 for the `?token=` channel with no
warning; silent breakage is the failure mode for stragglers. The
back-compat reader `check_local_token` (used by `sf-config-compare{,ec}`
and `sf-metadata-vault`) keeps emitting its existing warning log past
the cutover on read-only report paths - the warning stays on by design.

**Pre-cutover sweep (mandatory by 2026-10-25):**
Run these fixed-string greps (no regex, copy-paste safe):

```
grep -rn --include='*.py' -F 'request.args.get("token' sapsf/
grep -rn --include='*.py' -F 'request.args.get("_token' sapsf/
grep -rn --include='*.py' -F 'legacy_query_arg' sapsf/
```

Together they must identify only the two implementation files in the next
bullet (test files may also pin this behavior). If they surface another
implementation file, the deprecation list is incomplete and
the cutover cannot proceed without an ADR amendment. The `-F` flag
keeps the patterns literal so an operator can paste them blind on
2026-10-25 and trust the output.

**Files to edit on 2026-10-25:**
* `sapsf/sf-object-sync/web_ui/app.py` -- drop the legacy branch in
`_require_token`; the regression pin
`tests/test_web_ui_auth.py::test_query_token_falls_back_and_logs_deprecation`
flips to a permanent 401 invariant.
* `sapsf/_shared/src/sapsf_shared/flask_base.py` -- decide on the
future of `check_local_token`'s `?token=` fallback per the
write-path-vs-read-path rule in the Compliance section. This ADR
does not force removal here; reviewers must choose per surface.

Local credential-management surfaces (see second table) are
unaffected by ADR-0003 -- they do not have an HTTP token channel.

## Applicability by surface type

ADR-0003 governs HTTP-token-gated Flask surfaces that accept an
`X-Auth-Token` / `?token=` channel. Local credential-management Flask
surfaces (CRUD on stored secrets, scenario packs, manifest review)
use a different model -- encrypted credentials-at-rest plus session
CSRF -- and are **not** covered by ADR-0003. Mixing them here would
mislead the next maintainer.

### HTTP-token-gated surfaces (ADR-0003 applies)

| Tool | Helper used | Channel accepted today | Migration target |
|------|-------------|------------------------|------------------|
| `sf-object-sync` (`web_ui/app.py`) | local `_require_token` | header + query (warn) | `require_local_token` (header-only) |
| `sf-config-compare` (`app.py`) | `check_local_token` | header + query | preserves caller-defined denial status |
| `sf-config-compare-ec` (`app.py` + `core/api.py`) | `check_local_token` | header + query | preserves caller-defined denial status |
| `sf-metadata-vault` (`app.py`) | `check_local_token` | header + query | preserves caller-defined denial status |

### Local credential-management surfaces (ADR-0003 does not apply)

| Tool | Auth model | Credentials at rest | Notes |
|------|------------|---------------------|-------|
| `sf-rule-tester` (`webapp/app.py`) | Session cookie + CSRF token; outbound SF passwords stored in `webapp/auth.py` | OS keyring (preferred) or `chmod 600` `.secrets.json` fallback | Loopback bind only (`127.0.0.1:5060`); enforced in `webapp/app.py:main()`. No inbound `X-Auth-Token` / `?token=` channel. `webapp/auth.py` is a credential **store**, not a request gate. |
| `sf-transport-pilot` (`src/sftp/webui/app.py`) | Per-tenant credentials encrypted `SecretBox` (libsodium) into SQLite; session CSRF on every POST | SQLite ciphertext + `data/.sftp_secret.key` per secret | Loopback bind enforced at the CLI in `src/sftp/cli.py` (`ui` command); the webui factory itself does not bind. CSRF is the write-protection gate -- a separate concern from any HTTP token. No `X-Auth-Token` / `?token=`. |

The migration target column above is informational -- rows marked
"preserves caller-defined denial status" are intentionally on the back-compat reader
because all are read-only report endpoints. Write-path gates
(`<tool>/<write-route>`) on those tools should adopt
`require_local_token` ahead of the 2026-10-25 cutover.

## Compliance

* **Write paths** MUST use `require_local_token` (header-only, with 403 for
client authentication failures and 503 for missing server configuration).
`sf-object-sync`'s local `_require_token` is
grandfathered through the 2026-10-25 cutover window only -- see
Operator action for the exact cutover rules. Emitting `?token=` to
any other hardened write surface after the cutover returns 401; no
new code path should accept the channel through a back-compat
reader.
* **Read paths** (report endpoints) may stay on `check_local_token` for
back-compat reads, provided every rejection emits an
`app.logger.warning` with `path=` and `remote=` so SOC can grep probe
traffic. `check_local_token` is not "banned"; it is the deliberate
reader helper, distinct from the writer helper.
* New shared Flask blueprints default to `require_local_token`; only fall
back to `check_local_token` when the endpoint is read-only and the
regression pin covers both channels.
* When `WEB_UI_TOKEN` is unset, `_require_token` short-circuits open.
Loopback binding is enforced **per tool**:
- `sf-object-sync` (Table 1) -- env-override via `HOST`
(`os.getenv("HOST", "127.0.0.1")`); soft default, NOT validator-
enforced. Operators must not override `HOST` on a public network.
- `sf-rule-tester` (Table 2) -- hardcoded
`app.run(host="127.0.0.1", port=5060)` in `webapp/app.py:main()`.
- `sf-transport-pilot` (Table 2) -- enforced at the CLI in
`src/sftp/cli.py` (`ui` command validator: rejects any host not in
`{"127.0.0.1", "localhost", "::1"}`).
112 changes: 112 additions & 0 deletions docs/DECISIONS_pending_owner_approval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# DECISIONS pending owner approval (cross-repo)

Per `sapsf/CLAUDE.md` archival rule: items in this file are awaiting an
explicit decision from the owner before any delete / archive / major rewrite
action is taken. Companion to `sapsf/PORTFOLIO_OPERATING_MODEL.md` and
`sapsf/SECURITY.md`.

Last reviewed: 2026-07-25 (sapsf portfolio audit review)

---

## 1. Workshop triplet canonical path

**Repos involved:** `sf-workshop-advisor/`, `sap-workshop-advisor/`,
`sap-integration-workshop-advisor/`

**Audit observation:** Three single-file HTML apps with overlapping content.
A canonical version should be chosen; near-duplicates archived.

**Why it is pending:** Each workshop advisor targets a slightly different
audience (SF-only, SAP general, SAP integration). The "canonical" version is
a product decision, not an engineering one.

**Options for the owner:**
1. Mark `sf-workshop-advisor/` canonical; archive `sap-workshop-advisor/` and
`sap-integration-workshop-advisor/` with redirects.
2. Keep all three; document the audience split in each README.
3. Merge under a shared `sapsf-workshops/` umbrella with per-audience
subdirs.

**Owner decision needed before:** any archive or merge action.

---

## 2. sf-position-integrity-checker archival

**Repo involved:** `sf_position_integrity_checker/` (with the underscore; the
directory `sf-position-integrity-checker/` is the live component)

**Audit observation:** Two near-duplicate repos exist. The active one is the
hyphenated one; the underscore variant is kept for historical reasons.

**Why it is pending:** Owner may still be receiving references from older
clients / partner docs to the underscore name.

**Options for the owner:**
1. Archive `sf_position_integrity_checker/` with a redirect README pointing
to the hyphenated variant.
2. Keep both; tag the underscore variant "frozen as of 2026-07-25".
3. Rename the live repo to match the underscore form.

**Owner decision needed before:** any rename or archive action.

---

## 3. sf-scope archival

**Repo involved:** `sf-scope-prep/` (the active component) and the
historical `sf-scope/`

**Audit observation:** `sf-scope/app.py` historically used `exec()` to run
`.pyc`-derived bytecode, which is unsafe. Replaced with a
`NotImplementedError` shim on 2026-07-25 (commit pending). The repo is
effectively retired.

**Why it is pending:** Owner wants to confirm no production deploy ever
relied on `exec_pyc`.

**Options for the owner:**
1. Archive `sf-scope/` now (no production usage confirmed).
2. Keep as reference for the audit trail; add an ARCHIVED.md marker only.
3. Re-implement `exec_pyc` using constrained, vetted patterns and resume.

**Owner decision needed before:** archive or rewrite.

---

## 4. sf_workflow_monitor archival

**Repo involved:** `sf_workflow_monitor/` (underscored, with
`.secrets.json`)

**Audit observation:** Project lifecycle unclear; secret-scanning flagged a
leaked credential on 2026-07-25 (now scrubbed). No recent commits; no
active CI.

**Why it is pending:** Owner needs to confirm whether the project is
retired, frozen, or in low-priority maintenance.

**Options for the owner:**
1. Archive repo now (tool superseded by sf-rule-tester + sf-cutover-planner).
2. Resume maintenance; reassign an owner.
3. Mark as "frozen / read-only" and add an ARCHIVED.md marker.

**Owner decision needed before:** any archive / resume action.

---

## How to record a decision

When the owner decides on any item above, add a dated note under the
item with:
- Option chosen
- Rationale (one sentence)
- Any constraints from the decision that future audits must respect

Then act on the decision. Update this file as items are resolved.

## How to add a new pending decision

Append a new section at the bottom with the same shape (repos involved,
audit observation, why pending, options, owner action needed before).
33 changes: 32 additions & 1 deletion src/sapsf_shared/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
"""sapsf-shared - Shared Python SDK for SAP SuccessFactors tools."""
"""sapsf-shared - Shared Python SDK for SAP SuccessFactors tools.

Note on top-level imports: the Flask helpers (``check_local_token``,
``require_local_token``) live in ``sapsf_shared.flask_base`` which imports
``flask``. They are NOT imported eagerly here; instead they are exposed
through PEP 562 module-level ``__getattr__`` so consumers reaching only for
auth-/config- side helpers do not pay the Flask import cost. They remain
findable via ``from sapsf_shared import require_local_token`` per ADR-0003.
"""

from typing import Any

from sapsf_shared.assurance import (
ASSURANCE_SCHEMA,
Expand Down Expand Up @@ -44,6 +54,27 @@
parse_sf_date,
)

# Lazy-imported Flask helpers: the sapsf_shared.flask_base module pulls in
# ``flask``. We expose them through the top-level namespace (findable via
# ``from sapsf_shared import require_local_token``) but defer the actual
# import so consumers that only need auth-/config- side helpers don't pay
# the Flask import cost. ``__getattr__`` is the PEP 562 mechanism for
# module-level lazy attribute resolution.
_LAZY_ATTRS = frozenset({"check_local_token", "require_local_token"})


def __getattr__(name: str) -> Any: # pragma: no cover - introspection helper
if name in _LAZY_ATTRS:
from sapsf_shared import flask_base

return getattr(flask_base, name)
raise AttributeError(f"module 'sapsf_shared' has no attribute {name!r}")


def __dir__() -> list[str]: # pragma: no cover - introspection helper
return sorted(set(globals()) | set(__all__) | _LAZY_ATTRS)


__all__ = [
"ASSURANCE_SCHEMA",
"AssuranceValidationError",
Expand Down
Loading
Loading