Repository navigation
v26.7.1 — Multi-tenant hardening for a shared deployment
Multi-tenant hardening for a shared deployment
This release is what a second caller found. The dworkers control plane runs one shared flycanon for all of its tenants, and wiring it against 26.7.0 turned up defects a single-tenant stack cannot see — where every process is the database owner and the only client is the one that wrote the docs. The API-key gate that was read by nothing is now enforced, an erased document is erased from the object store and not only from the index, outbound fetches and callbacks refuse anything that is not globally routable, callbacks are signed, a whole workspace can be purged, search returns rows under the NOBYPASSRLS serving role that docs/deployment.md prescribes, the boot-time DDL that role could not run has moved into a migration, and the RLM can finally be pointed at Claude 4.6+ / 5.
Security
FLYCANON_API_KEYSis now enforced. The setting existed since the first release and was read by nothing: anyone who could reach port 8500 could read or write any tenant and mint agent tokens for it.ApiKeyMiddlewarenow requiresX-API-Key: <key>orAuthorization: ApiKey <key>on every/api/v1/*route exceptGET /api/v1/version, and on the admin dashboard, whenever at least one key is configured (401 missing_api_key/401 invalid_api_key, RFC 7807, constant-time comparison)./api/v1/agent/*requests that carryX-Agent-Tokenare exempt -- the token is their credential. With the setting empty the service stays open as before and the boot log says so (api-key gate DISABLED); with keys it logsapi-key gate ENABLED: n key(s).- Admin dashboard requires authentication (
pyfly.admin.require-authdefaults totrue, overridable withFLYCANON_ADMIN_REQUIRE_AUTH=falsefor loopback dev). A validated platform key is bridged into pyfly'sSecurityContext(ApiKeyPrincipalFilter) so the dashboard answers to the key and refuses everything else. - Outbound host policy (SSRF guard).
urifetches andcallback_urlwebhooks refuse every address that is not globally routable -- loopback, private (RFC 1918 / ULA), link-local (instance metadata), multicast, reserved, unspecified, RFC 6598 shared address space (100.64.0.0/10: CGNAT, Tailscale, the pod CIDR of most managed Kubernetes clusters), site-local, documentation and benchmarking blocks; the decision isipaddress'sis_global, the named checks only shape the message -- literal, DNS-resolved, and on every redirect hop (redirects are now followed by hand, at most five) -- with400 url_fetch_forbidden_host/400 callback_url_not_allowed.FLYCANON_URL_FETCH_ALLOW_PRIVATE=truelifts the denylist for private dev stacks. Fetcher failures now render as problem+json (url_fetch_*codes, 400 / 413 / 502) instead of the framework 500. Documented gap: DNS rebinding between check and dial. - Signed webhooks. Async-ingest callbacks carry
X-Flycanon-Signature: t=<unix>,v1=<hex HMAC-SHA256 over the raw body>whenFLYCANON_WEBHOOK_SECRETis set (unsigned + boot warning otherwise), and the payload now carriestenant_id/workspace_id.verify_signatureships inflycanon.web.conventions.webhook_signature.
Added
- User-tier
DELETE /api/v1/sources/{id}(204;404 source_not_found). Both it and the agent-tier DELETE now delete the stored original from the object store as well as the index, chunks and row -- before, an "erased" document stayed readable by the RLM corpus.IntakeService.removereturns aSourceRemoval, and itsoriginal_deletedis a fact, not a promise: the store is asked whether the object exists before the (no-op-on-missing) delete, and a key that is not in this process's store is logged as a warning and reportedfalse-- the audit payload, theSourceRemovedevent and the purge counter all carry that same value. POST /api/v1/workspaces/{id}:purge->WorkspacePurgeResult. Removes every source through the full pipeline, then knowledge items / versions / citations / relations, candidates, conversations / turns, ingest jobs / events and cost events in one transaction, closes the workspace and emitsWorkspaceDeleted; audit rows are kept and aworkspace.purgedrow records the counts.X-Workspace-Idmust equal the path id (400 workspace_scope_mismatch). Idempotent in the literal sense: every counter is what this call erased,originals_deletedcounts objects actually found and deleted, andclosedistrueonly for the call that moved the row toclosed(WorkspaceRepository.close_if_open); a repeat is all zeros withclosed: false, restamps nothing, publishes no secondWorkspaceDeletedand writes no second audit row.- Job-stream resume.
GET /api/v1/ingest-jobs/{id}/streamaccepts?after_id=<id>and the standardLast-Event-IDheader, and stampsid:on everyeventframe, so a reconnect no longer replays the whole history (the docs had promisedafter_idsince 26.5). FLYCANON_ADMIN_DATABASE_URL.WorkspaceRepository.list_for_tenant(GET /api/v1/workspaces) runs on this BYPASSRLS engine; under the productionflycanon_approle the request engine could only ever return the caller's own header workspace.- OpenAPI wire contract.
openapi.jsondeclaressecuritySchemes(ApiKeyHeader,ApiKeyAuthorization,AgentToken) and reusable header parameters (X-Tenant-Id,X-Workspace-Id,X-Correlation-Id,Idempotency-Key) attached to every operation, so a generated client sends the mandatory headers.securitydescribes what satisfies the operation, not what the middleware lets through:/api/v1/agent/*listsAgentTokenalone (the platform key is neither required nor accepted there -- the route answers401 missing_agent_token), every other tenant operation lists the two platform-key forms. - Scope on ingest events. Every
flycanon.ingestpayload (SourceIngested,SourceReplaced,SourceRemoved,SourceIngestionFailed,IngestSourceRequested,IngestSourceFinished,IngestSourceFailed) carriestenant_id/workspace_id-- including theIngestSourceRequestedthe stuck-job sweep republishes:IngestJobRepository.reclaim_stuckreturnsReclaimedJob(job_id, tenant_id, workspace_id)and the service has no unscoped publish path.
Changed
- Conversations use the same engine as
/query.ConversationServiceis wired toAnswerDispatcher, soPOST /conversations/{id}/turnanswers with RLM by default and only with the deprecated RAG engine underFLYCANON_ANSWER_MODE=rag(then with theX-Flycanon-Deprecationheader, like/query). - Framework pins moved to pyfly
v26.09.04and fireflyframework-agenticv26.06.14; the unit suite, ruff and pyright pass unchanged on them.uv.lockis now committed and CI / the Dockerfile runuv sync --locked. - API and worker no longer share a Postgres-outbox consumer group. pyfly's CQRS auto-configuration subscribes a cache-invalidation bridge on
*in every process, so an API on the worker's group advanced the shared cursor pastIngestSourceRequestedevents and?mode=asyncjobs stayedqueuedforever. The API defaults toflycanon-api,flycanon workertoflycanon-workers(FLYCANON_EDA_GROUPoverrides either). FLYCANON_RLM_ANSWER_MODELremoved.CanonSettings.rlm_answer_modelshipped with the RLM settings in 26.7.0, documented as "the model for the final single-shot answer synthesis", and was read by nothing: the RLM's final answer is produced by the root model, either as thefinal(...)tool call of the CodeAct loop or as the tool-less forced-final turn when the loop runs out (bothchat_rawturns onFLYCANON_RLM_ROOT_MODEL); the sub model serves thellm()/rlm()helpers and the self-consistency selector. There is no single-shot synthesis step for a third model to drive, so the knob is gone rather than wired;CanonSettingsignores unknownFLYCANON_*variables, so an env file that still sets it boots unchanged. The docs,env_templateand the README now name two RLM models and say which one answers (tests/unit/rlm/test_which_model_answers.pyrecords the request bodies of a whole session and pins it).- Both entry points export pyfly's
_PYFLY_SERVER_*variables. pyfly logsserver_startedin every process with0.0.0.0:8080as the fallback.flycanon serveexportsuvicorn/0.0.0.0/FLYCANON_PORTso the line reports the real socket;flycanon worker, which runs no server, exportsnone/-/0so the line cannot claim a listener the process does not hold, and logs its own "no HTTP listener in this process" line beside it.
Fixed
- The shipped image could not ingest. The localfs object-store default
./var/objectsresolved to the root-owned/app/varunder thecanonuser, so the first ingest failed with EACCES. The image now exportsFLYCANON_OBJECT_STORE_LOCALFS_ROOT=/app/canon-data/objects(volume-backed) and pre-creates/app/canon-data/objectsand/app/var/objectswithcanonownership. - Sandbox child on macOS.
RLIMIT_ASis not settable on Darwin; the RLM sandbox runner now tolerates that refusal there (CPU and file-size caps still apply) instead of dying at startup, which had failed 27 unit tests on every developer Mac. - Zero search hits under the application role.
PostgresCorpus(the BM25 channel) ran its reads on a bare Core connection of its own engine, where the ORMafter_beginlistener that sets theapp.tenant_id/app.workspace_idGUCs never fires; under FORCE RLS aNOBYPASSRLSserving role (flycanon_app, asdocs/deployment.mdprescribes) therefore saw no chunk on the lexical channel and no row on the RRF hydration, and/searchand/agent/searchansweredhits: []while the vector channel alone found the chunk. Invisible in a stack where every process is the database owner. Every scoped corpus read now binds the GUCs itself, from the explicit(tenant_id, workspace_id)it is handed, throughset_config(..., is_local => true)inside the read's own transaction.tests/integration/test_search_under_app_role.pyruns the realRetrievalService.searchas a NOBYPASSRLS role and gets the row;tests/unit/test_postgres_corpus_scope_filter.pypins the corpus alone, with the bare-connection control that returns nothing. - DDL at boot under the serving role.
canon_chunk_vectors(frameworkPgVectorVectorStore: extension, table, HNSW + namespace indexes, flycanon's RLS policy) and PyFly'spyfly_eda_outbox/pyfly_eda_offsetswere created lazily by whichever process first touched them. PostgreSQL checksCREATEon the schema before it honoursCREATE TABLE IF NOT EXISTS, and demands ownership beforeCREATE INDEX IF NOT EXISTS, so a serving role with USAGE + DML could not boot the dense store (its first ingest died withpermission denied for schema public). New migration0016_boot_created_tablescreates all three as the migration role (the vector table sized byFLYCANON_EMBEDDING_DIMENSIONSand theFLYCANON_PGVECTOR_HNSW_*settings of the migrate job, the outbox tables from PyFly's own DDL constants), andRlsPgVectorVectorStore._create_schemais verify-first: on an existing table it runs no DDL and refuses a width other than the configured one (VectorStoreError, before the first vector is written). PyFly's outbox DDL still runs instart()-- an upstream ask -- so a non-owner serving role keepsCREATEon the schema and membership of the owning role until it ships (docs/deployment.md). temperaturerefused by Claude 5 / 4.7 / 4.8. The RLM client senttemperature: 0.0on every Messages call;claude-sonnet-5answers400 temperature is deprecated for this model, soFLYCANON_RLM_*_MODELcould not name a current model.request_shape(model)now classifies the id: Claude 4.6 and later (claude-*-5*, Opus 4.6/4.7/4.8, Sonnet 4.6) getthinking: {"type": "adaptive"}, no sampling knobs, and amax_tokensfloor of 8192 (thinking tokens count against the budget; the old 1500 tool-turn ceiling came back asstop_reason: max_tokenswith notool_use, which the session read as an empty plain-text answer); Haiku 4.5, Sonnet 4.5 and any id that does not parse keep the deterministictemperature: 0.0byte for byte. Price rows added for Sonnet 5, Opus 5, Opus 4.6/4.7; Opus 4.8 corrected from the old Opus tier (15/75) to 5/25.
Docs + SDK
- README, QUICKSTART,
docs/payload-reference.md,docs/api-reference.md,docs/async-ingest.md,docs/security-model.md,docs/deployment.md,docs/eda-events.md,docs/conversations.md,docs/pipeline.mdandenv_templatedescribe the routes that exist (JSONcontent_base64intake,?mode=async,/query/stream,/conversations/{id}/turn,/query/suggest,/ingest-jobs/{id}/stream?after_id=), the new verbs, headers and settings, and theno_answercontract. - The Python SDK is realigned with the served routes and bumped to
26.7.1(seesdks/python/CHANGELOG.md).
Python SDK
Install the wheel for this release with uv:
uv add https://github.com/firefly-operationOS/flycanon/releases/download/v26.7.1/flycanon_sdk-26.7.1-py3-none-any.whlOr pin it in your pyproject.toml:
[project]
dependencies = ["flycanon-sdk"]
[tool.uv.sources]
flycanon-sdk = { url = "https://github.com/firefly-operationOS/flycanon/releases/download/v26.7.1/flycanon_sdk-26.7.1-py3-none-any.whl" }Java SDK
<dependency>
<groupId>com.firefly</groupId>
<artifactId>flycanon-sdk</artifactId>
<version>26.7.1</version>
</dependency>Resolve from GitHub Packages -- add to your pom.xml:
<repositories>
<repository>
<id>github-firefly-operationOS</id>
<url>https://maven.pkg.github.com/firefly-operationOS/flycanon</url>
</repository>
</repositories>What's Changed
- ci: cap every job with a timeout by @miguelgfierro in #72
- feat(26.7.1): multi-tenant hardening for a shared deployment by @ancongui in #73
Full Changelog: v26.7.0...v26.7.1