Skip to content

feat(api): add the Task API skeleton behind api.enabled - #70

Closed
HecFranco wants to merge 8 commits into
mainfrom
feat/213-s2-api-skeleton
Closed

HecFranco wants to merge 8 commits into
mainfrom
feat/213-s2-api-skeleton

Conversation

@HecFranco

@HecFranco HecFranco commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Third slice of spec 213. Stacked on #69, which is stacked on #67; the base is main because CI runs only on PRs to it, so this diff shows all three until they merge. Audit status re-verification pending. Touches authentication and permissions: immediate independent audit before the tag (SC-005).

Skeleton only: GET /v2/user, GET /v2/team, and everything later endpoints go through.

  • Pipeline (TaskApiRequestListener, only on API routes, below the firewall): failed-token limit per IP, credential from the Authorization header only, authenticator, security user must be the token's user, access checker, rate limit, X-RateLimit-*, no-store, one {err, ECODE} shape for refusals, unknown paths under the prefix and crashes (no message leaks).
  • Guard (TaskApiGuard, public): 404 out of reach, then 403 PLAN_001, TOKEN_001, ACCESS_002, per request. Levels are a table (TaskApiCapability), not a chain.
  • Hooks (Api\Hook): the authenticator is required; every other default denies. TaskApiTimeEntriesInterface has no delete (owner).
  • Build refuses security.allow_unauthenticated, and security.resource_scoping: false without api.host_decides_access: true.

Off-host proof

tests/Functional/Api/ApiOffHostTest: with api.enabled: false, even with hooks set: no route, no service of the API, no listener, no parameter, no table; the path answers like any unknown path; setting the option off equals not setting it.

Also compared by hand: routes, service ids, kernel listeners and the full CREATE TABLE SQL of the functional kernel, before (#69) and after this change: no difference.

Not in this slice

The idempotency store's default (DoctrineTaskApiIdempotencyStore, S4), so there is no table yet even when on; the API_WRITE listener check and quota_listener_required (S4, with the first write; config only for now); the time delete refusal TIME_002 (S5).

For the auditor

  • requireSecurityUser(): identity by id or object, 500 on mismatch, and ApiWithoutFirewallTest.
  • isApiRequest(): an unmatched path under the prefix answers without the pipeline: it reveals nothing, and there is no route to reach.
  • isOwn(): creator or Assignee; Watcher, Reviewer, Stakeholder do not count.
  • A token that passes the host's firewall but fails canAccess answers 401 and is not counted as a failed token.

Test tooling

phpunit.xml.dist raises memory_limit from 512M to 1G: the HTML coverage report ran out of memory in CI. Main already peaks near 440M before writing it (measured at #69: 439M, passing; this branch: 443M, failing), so the margin was gone, not just used by this slice.

Gates run locally

PHP 8.2: composer test-coverage-100 (1567 tests, 100%), PHPStan, cs-check.

Not for merge until the coordinator's audit step.

Audit IA fixes

  • IA-L1: a token whose user canAccess refuses keeps its 401 and counts as a failed token (testATokenWhoseUserIsRefusedCountsAsAFailedToken).
  • IA-L2: with tenancy the guard asks the bundle's tenant resolver, as the UI does (ApiTenancyTest, permissive workspace hook).
  • IA-I4: board ids in where grant on their own, documented as intended.
  • IA-I2, I3, I5: notes in SECURITY and docs/API.md.
  • Takes refactor(tasks): give comment, restore and field rules one home #69 by merge.

…pec 213 S0)

Pins what the UI answers before the rules behind it move in the next slice:
status, Location, flashes, log lines, events and history for the comment and
restore routes, and the constraints of the six write forms. No src change.

With TASK_BOARD_CHARACTERIZE_DIR set the tests also write the pages with ids and
dates masked, so two checkouts on one vendor can be compared byte by byte.
… 213 S1)

A write path other than the UI's forms must apply the same rules, so they leave
the controller and the form types: TaskCommentPolicy, TaskManager::restore() and
Validation\TaskConstraints. tests/Functional/Characterization from the previous
commit is unchanged and passes, and the rendered pages are byte-identical.

Adds what the UI does not have and the API will need, with no caller yet:
TaskBoardEraser::eraseTask() (leaf-first, so PreRemove keeps every minute; refuses
a task that follows a provider), TaskBoardEditor, TaskManager::archive(),
TaskDependencyManager::findBetween(), TaskFormData::fromTask().
Off by default: no route, service, listener, parameter or table for a host that
changes nothing. On: stateless routes under api.prefix, GET /v2/user and /v2/team,
and what every later endpoint goes through: the request pipeline, TaskApiGuard
(level, the person's access and where, per request), the capability table and one
{err, ECODE} shape.

The bundle stores no tokens. The hooks live in Api\Hook, the authenticator is
required and every other default denies. It fails closed when the security user is
not the token's user, as access checkers read the security token, and the build
refuses a configuration that would open every board to every token.

Audit status: re-verification pending.
The HTML coverage report ran out of 512M with the API tests: main already peaks
near 440M before it. The PHPUnit ceiling goes to 1G, and the API tests compile
fewer kernels: one host configuration for the pipeline and the guard, and the
"off equals unset" check moves to the container builder.
…d-moves

# Conflicts:
#	docs/CHANGELOG.md
#	docs/UPGRADING.md
…audit IA)

IA-M1: eraseTask() handed the host no stored-file refs. After an unlink the
sync's attachment rows outlive the link (GWd-M1), so the files were never named
again; on SQLite the rows stayed orphaned. The refs of the subtree are read
first, passed per task in TaskErasedEvent, and the rows removed. Proof test
ported from the auditor; run on SQLite and PostgreSQL 16.

IA-I1: TaskBoardManageController keeps $syncLimits in its place, deprecated
and ignored, so positional arguments do not move in a minor release.

Also takes main (2.1.1) and the 1G PHPUnit ceiling the coverage report needs.
…t IA)

IA-L1: a valid token whose user the access checker refuses keeps its 401 and
now counts as a failed token, so it cannot call the authenticator unthrottled.

IA-L2: with tenancy the guard asks the bundle's tenant resolver for the board's
tenant, as the UI does through owns(), so a permissive workspace hook opens no
other tenant; a board without a tenant is not served under tenancy.

IA-I4: where's board ids grant on their own, documented as intended.
IA-I2, I3, I5: notes in SECURITY and API docs, no code change.
@HecFranco

Copy link
Copy Markdown
Contributor Author

Included in #76 (squash ebbd373), released in v2.2.0.

@HecFranco HecFranco closed this Nov 8, 2026
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