Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
116 commits
Select commit Hold shift + click to select a range
0f5d9eb
feat: add network access policy console API
zyssyz123 Aug 20, 2026
4dc94b7
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 20, 2026
642c110
test: satisfy strict API type checks
zyssyz123 Aug 20, 2026
2c254a4
feat: manage reusable network access groups
zyssyz123 Aug 21, 2026
5fad259
test: satisfy strict network access checks
zyssyz123 Aug 21, 2026
9347c07
fix: clean app network binding after deletion
zyssyz123 Aug 24, 2026
25e9f8c
Merge remote-tracking branch 'origin/main' into song/snp-682-ip-allow…
zyssyz123 Aug 24, 2026
e35de2d
fix: align network access authorization with main
zyssyz123 Aug 24, 2026
c015442
feat(web): add Access Control paywall entry on Access Point
Sep 8, 2026
3b9b9d4
feat(web): add Access Control L2 config panel on Access Point
Sep 8, 2026
69684a3
feat(web): add Access Control chip states for paid first-open
Sep 8, 2026
7e88630
feat(web): add Settings IP Policy create dialog
Sep 9, 2026
72af960
fix(web): keep IP Policy dialog corners round when allowlist scrolls
Sep 9, 2026
7e3ceab
feat: align network access BFF with app policies
zyssyz123 Sep 9, 2026
c0bd864
fix: harden network access policy compatibility
zyssyz123 Sep 9, 2026
49b8284
fix: normalize app access points by mode
zyssyz123 Sep 9, 2026
5ec3f43
fix: align paid plan entitlement contract
zyssyz123 Sep 9, 2026
1d9862b
fix: separate network access API URL
zyssyz123 Sep 9, 2026
7752590
Merge remote-tracking branch 'origin/main' into song/snp-682-ip-allow…
zyssyz123 Sep 9, 2026
1c3d5fc
fix: avoid dynamic icon type lookup
zyssyz123 Sep 9, 2026
4f82c19
test: use validated config overrides
zyssyz123 Sep 9, 2026
e8b965a
refactor: migrate network access group APIs
hjlarry Sep 10, 2026
d861a0f
Merge branch 'feat/ip-access-control' into song/snp-682-ip-allowlist
Sep 10, 2026
f6a7e64
feat(web): persist Access Control through network access groups
Sep 10, 2026
225a04c
test(web): cover IP Policy create posting to network access groups
Sep 10, 2026
d9a9075
fix(web): match IP Policy Used by chips to Figma
Sep 10, 2026
f918053
fix(web): restyle Settings IP Policies list as a table
Sep 10, 2026
9e985c2
fix: complete network access control backend contracts
zyssyz123 Sep 11, 2026
d2de3fe
chore: satisfy network access CI style checks
zyssyz123 Sep 11, 2026
7c95008
refactor: reuse network access point conversion
zyssyz123 Sep 11, 2026
1fdbbf4
fix(web): create IP policies in-place and keep apply-to independent
Sep 11, 2026
96ea949
fix(web): drop come-back copy from IP policy empty states
Sep 11, 2026
5ef488e
feat(web): restore Access Control drafts and add downgrade resume pay…
Sep 11, 2026
4dab47f
feat(web): show IP access restrictions across public app requests
Sep 17, 2026
892c6ee
fix(web): let editors inspect network policies without workspace muta…
Sep 17, 2026
e4d98dd
fix(web): align IP access controls with app capabilities and Agent ac…
Sep 17, 2026
aecdac0
fix(web): match disabled access point label to design
Sep 17, 2026
a2c0868
fix(ci): expose API documentation converter errors
Sep 17, 2026
b2a0bc1
fix(ci): use system npm with pnpm-managed Node
Sep 17, 2026
fbc723d
Update .github/actions/setup-web/action.yml
lyzno1 Sep 17, 2026
9d1cfbf
feat(web): align IP policy deletion dialog with design
Sep 20, 2026
f01cdb3
fix(api): expose trusted current IP before policy creation
zyssyz123 Sep 20, 2026
38eb8f6
fix(dev): restore SaaS workflow queue consumers safely
zyssyz123 Sep 20, 2026
148db42
feat(web): unify app not found and IP access error pages
Sep 20, 2026
c1944ca
fix(web): align IP access control copy with product specification
Sep 20, 2026
bbfca74
fix(web): open IP policies for editing from rows (SNP-833)
Sep 20, 2026
94a15b1
fix(web): label IP policy counts as total references (SNP-828)
Sep 20, 2026
e58d4f3
fix(web): route IP policy references to bound agents (SNP-829)
Sep 20, 2026
e44f95b
fix(web): accept IPv4-mapped IPv6 policy entries (SNP-830)
Sep 20, 2026
b2a7219
fix(web): add trusted current IP to policy allowlists (SNP-835)
Sep 20, 2026
c5a9e9b
fix(web): check current IP before saving access control (SNP-826)
Sep 20, 2026
f6c9869
fix(web): disable scopes until a valid IP policy is selected (SNP-831)
Sep 20, 2026
df65412
fix(web): persist access control pause and resume immediately (SNP-827)
Sep 20, 2026
0f487e5
fix(web): preserve drafts and expand newly created policies (SNP-834)
Sep 20, 2026
94b4591
test(web): guard shared IP policy edit warnings (SNP-832)
Sep 20, 2026
2601674
fix(web): add sign-in entry to inaccessible app page
Sep 20, 2026
cce8568
fix(web): clarify Chinese IP policy reference labels (SNP-828)
Sep 20, 2026
04f3ed7
fix(api): include trusted client IP in app access 404 errors
zyssyz123 Sep 20, 2026
19dd94b
fix: show upgrade status when IP access control expires
Sep 20, 2026
d9d92fa
fix: resume IP access control when saving paused settings
Sep 20, 2026
ec41661
fix: preserve access control drafts when closing the panel
Sep 20, 2026
b3fb4e7
fix(api): align access failures with native endpoint contracts
zyssyz123 Sep 21, 2026
e8f8728
fix: distinguish paused access control from off
Sep 22, 2026
c721ec5
fix: complete access control translations and plan messaging
Sep 22, 2026
0a4f5d0
fix: clarify access control toggles and protection tooltips
Sep 22, 2026
604d785
fix: align access control editor permissions with PRD
Sep 22, 2026
388e663
fix(api): require published apps before public web admission
zyssyz123 Sep 22, 2026
aaf3076
fix(web): return opaque document 404 before rendering public apps
zyssyz123 Sep 22, 2026
343fc36
test(api): move SaaS dev queue repair tests into API suite
zyssyz123 Sep 22, 2026
e4a2c43
chore: remove SaaS dev queue repair tooling from feature PR
zyssyz123 Sep 22, 2026
4e1b63c
fix(api): skip unrelated bodies in app access error hook
zyssyz123 Sep 23, 2026
af693a7
fix(api): isolate MCP body parsing from database sessions
zyssyz123 Sep 23, 2026
d07e2d2
refactor(api): type network access control plane boundaries
zyssyz123 Sep 23, 2026
a22026a
test(api): annotate MCP request lifecycle regression helpers
zyssyz123 Sep 23, 2026
1378a72
fix(web): restore sign-in from unavailable app page (SNP-852)
Sep 23, 2026
3eb6fe3
fix(web): refresh IP policy after version conflict (SNP-853)
Sep 23, 2026
bec385b
fix(web): gate Chatflow access points on publication (SNP-854)
Sep 23, 2026
954ba55
Merge remote-tracking branch 'origin/main' into song/snp-682-ip-allow…
Sep 29, 2026
34d831e
Merge remote-tracking branch 'origin/main' into song/snp-682-ip-allow…
Sep 29, 2026
4e96441
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 29, 2026
20a8125
fix(web): align add IP policy dropdown action with design
Sep 29, 2026
2b4eee8
fix(web): sync generated types for IP policy label
Sep 29, 2026
b47d5fb
fix(ci): align IP access control checks and tests
Sep 29, 2026
8795a5e
fix(ci): satisfy Python boundary guards and MCP checks
Sep 29, 2026
d14569a
Merge main into IP access control and adapt backend boundaries
zyssyz123 Oct 8, 2026
3f478c8
test(api): use real clients for network access regression
zyssyz123 Oct 8, 2026
da21c38
feat(web): track IP access control analytics
Oct 8, 2026
9ea1b65
feat(web): add Settings IP policies empty state
Oct 9, 2026
e79d985
fix(web): stabilize IP policy current IP row height (SNP-945)
Oct 10, 2026
e1b130b
fix(web): restore IP lockout confirmation spacing (SNP-939)
Oct 10, 2026
e859efa
fix(web): pluralize IP policy address counts (SNP-938)
Oct 10, 2026
96f5fff
fix(web): align IP access control copy with spec (SNP-942)
Oct 10, 2026
d7394a4
fix(web): route new Agent access points to Agent page (SNP-943)
Oct 10, 2026
28021e9
fix(api): preserve IP policy display entries and user-facing conflicts
zyssyz123 Oct 10, 2026
a384983
fix(web): pluralize IP policy summary counts (SNP-938)
Oct 10, 2026
fc821e2
fix(web): preserve IP policy address input (SNP-944)
Oct 10, 2026
3c7c411
fix(web): align IP access control translations (SNP-949)
Oct 12, 2026
879bbca
fix(web): keep settings visible during content loading (SNP-953)
Oct 12, 2026
0d5e7da
fix(web): clarify read-only IP policy details (SNP-950)
Oct 12, 2026
accae15
fix(web): correct editor IP policy empty states (SNP-954)
Oct 12, 2026
033d99a
fix(web): preserve input-aware menu and panel focus (SNP-952)
Oct 12, 2026
a76ed55
fix(web): align sandbox access control upgrade copy (SNP-955)
Oct 12, 2026
1ac214e
fix: clear stale account menu pointer focus ring (SNP-952)
Oct 12, 2026
2b8d97b
fix(web): extend IP policy row interaction area (SNP-956)
Oct 12, 2026
f956aa8
fix: respect Normal member access point read permissions
Oct 12, 2026
23e40a2
fix: read member access status from a dedicated control plane endpoint
Oct 12, 2026
60ef807
revert: withdraw SNP-951 changes pending backend coordination
Oct 12, 2026
2eb2029
fix(web): respect Normal member access point permissions (SNP-951)
Oct 13, 2026
1c72f7e
test(web): support deploy branch customize dialog export
Oct 13, 2026
847d936
fix: read member access status through a summary endpoint (SNP-951)
zyssyz123 Oct 13, 2026
36ae13e
fix(web): align IP access control plan messaging (SNP-985, SNP-986)
Oct 19, 2026
b6357e0
Merge main and address network access review feedback
Oct 20, 2026
a3c958a
fix: restore network access CI and management policy reads
Oct 20, 2026
5d9d07a
fix(web): keep error page height owned by its layout
lyzno1 Oct 20, 2026
db6c466
docs(web): remove one-off document gate validation report
lyzno1 Oct 20, 2026
109cbdd
Merge branch 'main' into song/snp-682-ip-allowlist
lyzno1 Oct 20, 2026
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
7 changes: 7 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ PYTHONIOENCODING=utf-8

# Console API base URL
CONSOLE_API_URL=http://localhost:5001

# Optional internal SaaS policy API; defaults to BILLING_API_URL when empty.
NETWORK_ACCESS_API_URL=
# Current-IP preview only. Comma-separated CIDRs, e.g. 172.18.0.0/16.
# Required for Cloud IP previews. Match Gateway's proxy boundary; never trust caller
# headers unless the edge sanitizes XFF or appends its verified socket peer.
NETWORK_ACCESS_TRUSTED_PROXY_CIDRS=
CONSOLE_WEB_URL=http://localhost:3000

# Service API base URL
Expand Down
15 changes: 15 additions & 0 deletions api/.importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ forbidden_modules =
sqlalchemy
werkzeug

[importlinter:contract:network-access-group-service-boundary]
name = Network access group application service is framework and persistence neutral
type = forbidden
source_modules =
services.network_access_group_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug

[importlinter:contract:dataset-application-boundary]
name = Dataset application services and contracts are framework and persistence neutral
type = forbidden
Expand Down
13 changes: 13 additions & 0 deletions api/configs/feature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,19 @@ class EndpointConfig(BaseSettings):

TRIGGER_URL: str = Field(description="Template url for triggers", default="http://localhost:5001")

NETWORK_ACCESS_API_URL: str = Field(
description="Optional internal SaaS API base URL for network access policy requests; "
"falls back to BILLING_API_URL",
default="",
)

NETWORK_ACCESS_TRUSTED_PROXY_CIDRS: str = Field(
description="Comma-separated trusted proxy CIDRs for network-access current-IP previews. "
"Only verified socket peers may supply X-Forwarded-For; empty disables Cloud IP previews. "
"Match the Gateway trust boundary and sanitize or append the client IP at the edge.",
default="",
)


class FileAccessConfig(BaseSettings):
"""
Expand Down
89 changes: 89 additions & 0 deletions api/controllers/API_SCHEMA_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,95 @@ return dump_response(WorkflowRunDetailResponse, workflow_run)
Use manual `model_validate(...).model_dump(...)` only when the endpoint needs behavior that `dump_response(...)` does
not provide, such as returning a non-dict payload, intentionally excluding fields, or composing a `(body, status)` tuple.

## Public access-error contracts

IP-policy denials must be indistinguishable from each surface's native
authentication/not-found errors. The Gateway terminates denied requests before
execution; Core owns the corresponding native error baselines. Keep their JSON
bytes (sorted keys, compact separators, no trailing newline), status and
application-owned headers aligned. Do not add policy names, internal reasons,
`ip_access_denied`, or a policy-specific response header.

| Surface | Error contract |
| --- | --- |
| Service API | 401 `unauthorized`, `Access token is invalid`; missing/malformed Bearer uses `Authorization header must be provided and start with 'Bearer'` |
| OAuth OpenAPI | Its own 401 `unauthorized` / `invalid_token`, not the Service API's token message |
| MCP | 404 JSON-RPC `-32600` / `Server Not Found`, echoing a string/number request ID or null |
| Workflow Webhook | 404 standard Werkzeug `NotFound()` HTML without capability ID/private detail; not JSON |
| Plugin callback | 404 JSON `{"error":"Endpoint not found"}`; not the Workflow Webhook HTML |
| WebApp | Canonical 404 `app_not_found` / `App not found.`, with optional trusted `client_ip` |
| HITL form / upload | Native form 404 / invalid-upload-token 403, not a WebApp error page |

These errors use `Cache-Control: no-store`. JSON uses `Content-Type:
application/json`; Workflow Webhook uses `text/html; charset=utf-8`. Service API
401 also includes `WWW-Authenticate: Bearer realm="api"`; OpenAPI preserves its
native challenge/extra fields. For callers: if an API key is confirmed correct,
contact the App administrator to check whether the source IP is allowed.

MCP resolves unavailable identity before validating an invocation. Its error-only
ID parser accepts at most 64 KiB of strict UTF-8 JSON, nesting depth 64; absent,
invalid or oversized IDs become null. Numeric lexemes retain precision. Allowed
requests retain their existing body and protocol-validation path. Do not create
EndUsers or execute tools just to construct a not-found response.

MCP identity preflight uses a short read session that ends before body reads or
parsing. Normal execution rechecks the same server/App/tenant identity in its
own transaction and uses fresh ORM objects; deletion, disabling or rebinding
during parsing must not execute against the stale identity. Construct opaque
404 responses outside both sessions. The error ID reader reuses existing raw
Werkzeug body-cache bytes without changing that cache, and otherwise reads at
most the limit plus one sentinel byte; it must not use unbounded `get_data()`
for unknown-length bodies. This error-only limit does not cap valid MCP bodies.

Real dependency failures stay fail-closed errors, not a missing-policy allow.
The Gateway can present an unresolved canonical WebApp reference as opaque404
while still terminating the request; it must never forward it because the
read-side miss could be replication lag. Internal audit/metrics retain the true
policy-denied or resolution-failure reason even though the public response does
not disclose it.

### WebApp and Console 404 metadata

The Web and Console blueprints add optional request-local `client_ip` metadata
to their JSON app-not-found responses. This is transport error metadata,
not an App property, a new endpoint, or a field in successful response DTOs.

- Public WebApp errors use only `code: app_not_found`, `message: App not found.`,
`status: 404` and optional `client_ip`. Strip exception-specific details that
would reveal whether an App exists. Authenticated Console management errors
preserve their original code/message/extra fields.
- A typed `app_not_found` 404 is eligible. Generic 404s are eligible only for
matched GET app-identity routes: Web bootstrap (`site`, `parameters`, `meta`,
`passport`, `login/status`, `webapp/access-mode`) and Console App/Agent,
installed-App detail/parameters/meta, and trial-App detail/parameters.
- The field is a single normalized IP resolved using the configured trusted
proxy boundary. Do not accept a caller's query/body IP or use unverified
forwarding headers. If trust configuration or resolution is unavailable,
omit `client_ip` instead of guessing a proxy address or changing the 404 to 503.
- These eligible errors use `Cache-Control: no-store`, because their IP metadata
must not be shared between callers through URL-based error caching.
- Known unavailable/unpublished WebApp identity errors use this same404; arbitrary
400s, auth errors and dependency503s do not. Public login/status and access-mode
must validate a supplied App reference even when user authentication is disabled.
- HITL keeps its own canonical form/token error; no App IP is attached. Ordinary
resource404s, unmatched routes, streams and Inner API contracts are unchanged.
In particular, an authenticated Console trial403 is not converted to404.

For example, either an unavailable Web passport or a Gateway WebApp denial is:

```json
{
"code": "app_not_found",
"message": "App not found.",
"status": 404,
"client_ip": "203.0.113.42"
}
```

The existing frontend error transport already accepts optional `client_ip`;
success OpenAPI/TypeScript/Zod/oRPC contracts do not change for this enrichment.
Add coverage at the final serialized-response boundary, including exceptions
with their own `data`, so Flask-RESTX cannot silently discard the metadata.
## Error Responses

Every 4xx/5xx response is documented with the surface's error body: `ErrorResponse` (`code`, `message`, `status`,
Expand Down
152 changes: 152 additions & 0 deletions api/controllers/common/access_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Native, non-cacheable access errors shared with the public gateway.

Keep these byte-level contracts local to their owning surfaces: Service API and
OpenAPI authentication, unavailable MCP identities, and missing trigger endpoints.
Successful responses and other APIs retain their existing serialization.
"""

import json
from dataclasses import dataclass
from typing import Any

from flask import Blueprint, Response, request
from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.wrappers import Response as WerkzeugResponse

MCP_ERROR_BODY_LIMIT = 64 * 1024
MCP_ERROR_MAX_DEPTH = 64
MISSING_BEARER_MESSAGE = "Authorization header must be provided and start with 'Bearer'"


def _json(value: Any) -> str:
# Match Go's non-HTML-escaping JSON encoder, including its JS-safe separators.
return (
json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True, allow_nan=False)
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
)


def register_auth_error_response(blueprint: Blueprint, *, www_authenticate: str | None = None) -> None:
"""Canonicalize local final 401 bodies, preserving native message/extra fields.

The Service API supplies its Bearer challenge explicitly. OpenAPI retains its
own challenge and OAuth error fields; other surfaces do not register this hook.
"""

@blueprint.after_request
def auth_error_response(response: Response) -> Response:
if response.status_code != 401 or not response.is_json or response.is_streamed:
return response
payload = response.get_json(silent=True)
if not isinstance(payload, dict):
return response
response.set_data(_json(payload))
response.headers["Content-Type"] = "application/json"
response.headers["Cache-Control"] = "no-store"
if www_authenticate is not None:
response.headers["WWW-Authenticate"] = www_authenticate
return response


@dataclass(frozen=True)
class _JSONNumber:
"""An already-validated JSON number lexeme, without float/int conversion."""

value: str


def _within_json_depth(body: bytes) -> bool:
"""Match the gateway's bounded parser; brackets inside strings do not count."""
depth = 0
in_string = False
escaped = False
for char in body:
if in_string:
if escaped:
escaped = False
elif char == 92: # backslash
escaped = True
elif char == 34: # quote
in_string = False
elif char == 34:
in_string = True
elif char in (91, 123): # array or object
depth += 1
if depth > MCP_ERROR_MAX_DEPTH:
return False
elif char in (93, 125):
depth -= 1
return True


def _invalid_constant(_value: str) -> None:
raise ValueError("Non-JSON numeric constant")


def _parse_mcp_error_request_id(body: bytes) -> str:
"""Parse only an error response's ID, retaining its original numeric lexeme."""
try:
if len(body) > MCP_ERROR_BODY_LIMIT or not _within_json_depth(body):
return "null"
payload = json.loads(
body.decode("utf-8"),
parse_int=_JSONNumber,
parse_float=_JSONNumber,
parse_constant=_invalid_constant,
)
if not isinstance(payload, dict):
return "null"
request_id = payload.get("id")
if isinstance(request_id, _JSONNumber):
return request_id.value
if isinstance(request_id, str):
# Reject unpaired surrogates instead of creating invalid UTF-8.
request_id.encode("utf-8")
return _json(request_id)
except (ValueError, UnicodeError, RecursionError):
pass
return "null"


def _mcp_request_id() -> str:
"""Read bounded raw bytes, including bodies already cached by request logging.

Werkzeug's public get_data() reuses _cached_data but otherwise reads the
entire stream. Its stream property does not replay that cache. Keep this
read-only private-attribute compatibility here: it avoids unbounded reads
for unknown Content-Length and never installs a partial request cache.
"""
try:
if request.content_length is not None and request.content_length > MCP_ERROR_BODY_LIMIT:
return "null"
cached = vars(request).get("_cached_data")
if cached is not None:
return _parse_mcp_error_request_id(cached) if isinstance(cached, bytes) else "null"
body = request.stream.read(MCP_ERROR_BODY_LIMIT + 1)
except HTTPException:
return "null"
return _parse_mcp_error_request_id(body)


def mcp_server_not_found_response() -> Response:
"""Opaque MCP lookup failure; no app/server identity or policy is disclosed."""
body = '{"error":{"code":-32600,"message":"Server Not Found"},"id":' + _mcp_request_id() + ',"jsonrpc":"2.0"}'
return Response(body, status=404, content_type="application/json", headers={"Cache-Control": "no-store"})


def webhook_not_found_response() -> WerkzeugResponse:
"""Use Werkzeug's normal HTML 404 without caller IDs or internal detail."""
response = NotFound().get_response()
response.headers["Cache-Control"] = "no-store"
return response


def plugin_endpoint_not_found_response() -> Response:
"""Plugin callbacks own a JSON not-found shape, unlike native Webhooks."""
return Response(
_json({"error": "Endpoint not found"}),
status=404,
content_type="application/json",
headers={"Cache-Control": "no-store"},
)
Loading
Loading