diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/cover-en.svg b/content/blog/how-to-let-anonymous-visitors-submit-a-form/cover-en.svg new file mode 100644 index 0000000..8abec6a --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/cover-en.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + PUBLIC FORMS ON OBJECTSTACK 17.7.0 + One public door, + five fields wide. + Anonymous data access is denied since v17. + A public form derives its grant from its own + declaration: create one row, read it back, done. + + 201 created + 401 on every read + 404 when withdrawn + + Measured on examples/app-crm, web_to_lead form, @objectstack/cli 17.7.0 + + + + The door, as the runtime sees it + Anonymous visitor sends + + + + + + + + name + email + company + phone + title + + + + + + + + status: converted + owner_id + organization_id + + + + + + + + + + + + crm_lead row + name Mallory + email mallory@… + status new + lead_score 0 + owner_id null + organization_id + stamped by server + + + + GET /api/v1/data/crm_lead + 401 + UNAUTHENTICATED + + POST /api/v1/forms/contact-us/submit + + diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/cover.svg b/content/blog/how-to-let-anonymous-visitors-submit-a-form/cover.svg new file mode 100644 index 0000000..6f9fc89 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/cover.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + OBJECTSTACK 17.7.0 公开表单 + 一道公开的门, + 五个字段宽。 + v17 起,匿名访问对象数据一律拒绝。 + 公开表单的授权从表单自身的声明推导: + 创建一行,读回这一行,仅此而已。 + + 201 已创建 + 每次读取都 401 + 撤回后 404 + + 实测于 examples/app-crm 的 web_to_lead 表单,@objectstack/cli 17.7.0 + + + + 运行时眼中的这道门 + 匿名访客发送 + + + + + + name + email + company + phone + title + + + + + + + status: converted + owner_id + organization_id + + + + + + + + + + + crm_lead 数据行 + name Mallory + email mallory@… + status new + lead_score 0 + owner_id null + organization_id + 由服务端盖章 + + + GET /api/v1/data/crm_lead + 401 + UNAUTHENTICATED + + POST /api/v1/forms/contact-us/submit + + diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.de.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.de.mdx new file mode 100644 index 0000000..9fb83c3 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.de.mdx @@ -0,0 +1,211 @@ +--- +title: "Wie lässt man anonyme Besucher ein Formular in eine governte Business-App senden? Eine öffentliche Tür, fünf Felder breit" +description: "Seit v17 wird anonymer Datenzugriff verweigert. Die einzige offene Tür ist eine öffentliche Formularansicht: das Recht kommt aus dem Formular — eine Zeile anlegen, sie zurücklesen, fünf Felder, sonst nichts. Gemessen auf 17.7.0." +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover-en.svg +tags: + - Öffentliche Formulare + - Web-to-Lead + - Anonymer Zugriff +--- + +**Kurzfassung.** Seit v17 wird eine nicht authentifizierte Anfrage nach Objektdaten in einer ObjectStack-App mit `401 UNAUTHENTICATED` abgewiesen — jedes Objekt, jeder Endpunkt, kein Opt-out. Die einzige Tür, die eine governte App der Öffentlichkeit offen lässt, ist eine **öffentliche Formularansicht**: Man deklariert ein Formular, setzt in seinem `sharing`-Block `allowAnonymous: true`, und die Runtime leitet die gesamte Berechtigung des Besuchers aus dieser Deklaration ab — einen Datensatz auf diesem einen Objekt anlegen, die gerade erzeugte Zeile zurücklesen, sonst nichts. Die Feldliste des Formulars ist die Sicherheitsgrenze. Das Web-to-Lead-Formular des CRM-Beispiels listet fünf Felder, also können fünf Felder ankommen; die übrigen acht Geschäftsfelder und jede Systemspalte können es nicht, was auch immer der Besucher schickt. Ich nenne das eine **formförmige Berechtigung** (form-shaped grant): eine Autorisierung, die zur Anfragezeit aus dem Formular berechnet wird — und deshalb nicht breiter konfiguriert werden kann als das Formular. Alles Folgende wurde auf der veröffentlichten `@objectstack/cli` 17.7.0 gemessen, der Version, die `npm create objectstack@latest` heute installiert — einschließlich der einen Frage, in der sich Dokumentation und mitgeliefertes Beispiel widersprechen: ob ein `guest_portal`-Profil nötig ist. Es ist es nicht. + +## Das Problem, in den Worten der Doku + +Der ObjectStack-Leitfaden zur öffentlichen Datenerfassung beginnt mit dem Szenario, so wie ein Anwender es formulieren würde: + +> Ich möchte, dass anonyme Besucher ein Formular — „Kontakt“, „Demo anfragen“, ein Aufnahmeformular — absenden, das einen Datensatz anlegt. Aber ich darf ihnen **keinen** Zugriff auf die Datenbasis geben, und sie dürfen nur die Felder auf dem Formular setzen können. + +Jede Business-App hat so ein Formular: das Lead-Formular auf der Website, die Support-Aufnahme, die Demo-Anfrage, den Garantieantrag. Die üblichen Antworten kosten jede etwas. Eine „Gast“-Rolle mit Schreibrecht auf die Tabelle ist eine stehende Berechtigung, die niemand mehr liest, und sie driftet. Ein handgeschriebener Endpunkt umgeht das Berechtigungsmodell, das alles andere regelt. Ein externes Formular-Tool mit Webhook bedeutet, dass nun zwei Systeme das Schema halten — und eines davon ist nicht das, das die Reviewer anschauen. + +Seit v17 steht die erste Antwort nicht einmal mehr zur Verfügung. Die Berechtigungs-Dokumentation hält den Grund nebenbei fest: „since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired“ (seit v17 wird anonymer Zugriff auf Objektdaten ohnehin immer verweigert — das Opt-out `requireAuth: false` wurde ausgemustert). Die [Anleitung zum Self-Hosting](/en/blog/how-to-self-host-objectstack/) macht daraus einen Prüfschritt — einen Objekt-Endpunkt ohne Anmeldedaten per `curl` aufrufen und auf der `401` mit maschinenlesbarem `code` bestehen — und behandelt eine `200` dort als Vorfall. Die ehrliche Form der Frage lautet also: Wenn die Datenebene für anonyme Aufrufer konstruktionsbedingt geschlossen ist, wie kommt ein öffentliches Formular überhaupt hindurch? + +## Ein öffentliches Formular ist eine Deklaration, keine Berechtigung + +Das ist das vollständige Web-to-Lead-Formular des CRM-Beispiels, wie es im Tag 17.7.0 ausgeliefert wird: + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +Nichts darin erwähnt eine Berechtigung. Der `sharing`-Block öffnet die Tür: Sind `enabled`, `allowAnonymous` und ein `publicLink` gesetzt, registriert die Runtime zwei anonyme Endpunkte — `GET /api/v1/forms/contact-us`, der das aufgelöste Formular und das gefilterte Schema zurückgibt, und `POST /api/v1/forms/contact-us/submit`, der ein `crm_lead` einfügt. Die Einzeilen-Regel der Doku dafür, was der Besucher schreiben darf, lautet: „Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side“ (beim Absenden werden nur die in `sections[].fields` gelisteten Felder akzeptiert; alles andere wird serverseitig entfernt). Und die Begründung, warum es so gebaut ist, ist der Satz, zu dessen Prüfung dieser Beitrag existiert: „Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form“ (Die Autorisierung wird aus der Deklaration abgeleitet, nicht separat konfiguriert — deshalb kann die Berechtigung nicht breiter driften als das Formular). + +![Zwei Netze: Die Route behält nur die fünf deklarierten Felder und verwirft serververwaltete Namen; die Datenschicht lässt nur Einfügen und Zurücklesen auf crm_lead zu und entfernt dieselben Namen erneut. Lesezugriffe antworten mit 401; ein zurückgezogenes Formular mit 404.](./two-nets-en.svg) + +Mechanisch gibt es zwei Netze, und sie verdienen Namen, weil ein Reviewer jedes einzeln prüfen kann. + +**Das erste Netz ist die Route.** Der Submit-Handler baut seine Positivliste aus `sections[].fields` des getroffenen Formulars und kopiert nur diese Schlüssel aus dem Request-Body. Davor verwirft er namentlich jeden Schlüssel aus `PUBLIC_FORM_SERVER_MANAGED_FIELDS`, einer Menge, die das Spec-Paket exportiert: `id`, `owner_id`, `owning_business_unit_id`, `organization_id`, `tenant_id`, `created_at`, `created_by`, `updated_at`, `updated_by`, `is_deleted`, `deleted_at` und `__search` — zwölf Namen, die „**never** accepted from a public submission — not even when a section declares them“ (niemals aus einer öffentlichen Einsendung akzeptiert werden — auch dann nicht, wenn eine Sektion sie deklariert). Ein Formular ganz ohne Felder wird mit `400` abgewiesen, statt alles anzunehmen, was ankommt. + +**Das zweite Netz ist die Datenschicht.** Die Route hängt dem Schreibvorgang ein anfragegebundenes `publicFormGrant: { object: 'crm_lead' }` an, und das Security-Plugin honoriert genau das: `insert`, dazu `find`, `findOne` und `count` für das Zurücklesen, und nur auf diesem Objekt. Bevor es das Einfügen zulässt, entfernt es dieselben zwölf Namen ein zweites Mal — selbst wenn ein Formular `owner_id` in einer Sektion deklarierte oder Routenfilter und Datenschicht irgendwann auseinander editiert würden, könnte ein Besucher weder Eigentum noch Mandantenzuordnung fälschen. Die Berechtigung ist die Form des Formulars, zur Anfragezeit gelesen. Es gibt keine Zeile in einer Berechtigungstabelle, die sagt „anonym darf Leads schreiben“, weil es nichts zu konfigurieren gibt. + +## Das Protokoll, auf 17.7.0 + +Ich habe `examples/app-crm` aus dem Tag `@objectstack/cli@17.7.0` genommen, seine Abhängigkeiten auf die veröffentlichten 17.7.0-Pakete festgenagelt und es mit `objectstack dev` gestartet. Der Dev-Server legt auf einer leeren Datenbank ein Admin-Konto an; die fünf vorbefüllten Leads gehören ihm. Jede Antwort unten ist echt und nur der Länge wegen gekürzt. + +**1. Die Tür beschreibt sich selbst — fünf Felder breit.** Ohne Anmeldedaten: + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +Das `objectSchema` enthält genau die fünf freigegebenen Felder. `crm_lead` deklariert in seiner Objektdefinition dreizehn Geschäftsfelder; das öffentliche Schema zeigt fünf und sagt nichts über `status`, `lead_score`, `assigned_to`, `account`, `notes` oder den Rest. Der Besucher kann nicht einmal erfahren, was er versuchen könnte. + +**2. Eine saubere Einsendung legt eine Zeile an.** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"` wurde nicht gesendet; es ist der Schreibpfad-Standardwert des Objekts, den das Lead-Objekt ausdrücklich deklariert, „so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required`“ (damit minimale Anlagen — etwa das öffentliche Web-to-Lead-Formular, das status nicht zeigt — `required` erfüllen). `source: "web"` und `lead_score: 0` sind ebenfalls Standardwerte. + +**3. Eine feindselige Einsendung legt dieselbe Zeile an.** Sechs Schlüssel gesendet; zwei stehen auf dem Formular: + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +Als Admin zurückgelesen ist die gespeicherte Zeile dieselbe: Die zwei freigegebenen Felder kamen an, die zwei Geschäftsfelder, die nicht auf dem Formular stehen, wurden verworfen, und die zwei Systemanker wurden verworfen und dann vom Server gestempelt — `organization_id` ist die eigene Organisation der Installation, nicht `org_other`, und `owner_id` ist `null`. Die `201` sagt nicht, welche Schlüssel abgewiesen wurden; die Route verwirft sie kommentarlos. + +| Gesendeter Schlüssel | Auf dem Formular? | Gespeichert als | Warum | +|:--|:--|:--|:--| +| `name`, `email` | ja | wie gesendet | freigegeben | +| `status: "converted"` | nein | `"new"` | nicht in `sections[].fields`; Objekt-Standardwert greift | +| `lead_score: 100` | nein | `0` | nicht in `sections[].fields`; Objekt-Standardwert greift | +| `owner_id: "attacker"` | nein | `null` | serververwalteter Name, in beiden Netzen abgewiesen | +| `organization_id: "org_other"` | nein | der eigene Wert des Servers | serververwalteter Name; von der Runtime gestempelt | + +**4. Die Tür öffnet sich nicht in die andere Richtung.** Derselbe anonyme Client, jetzt lesend: + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +Beide antworten `401` — die Liste und die einzelne Zeile, die der Besucher eine Sekunde zuvor angelegt hat. Der `201`-Body ist das einzige Mal, dass der Absender diesen Datensatz je sieht. „The public path only ever **inserts**; it never lists“ (Der öffentliche Pfad fügt nur ein; er listet nie). + +**5. Das Formular zurückziehen schließt beide Endpunkte.** `publicLink` behalten, `enabled: false` setzen, neu bauen, neu starten: + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +Die als Admin gelesene Lead-Zahl blieb vorher wie nachher bei 15. Vier Statuscodes erzählen die ganze Geschichte der Tür: `201`, `201`, `401`, `404`. + +## Welche Geschichte trägt: das Gastprofil oder die Deklaration? + +Hier widersprechen sich Dokumentation und mitgeliefertes Beispiel, und ich wollte nicht durch Lesen Partei ergreifen. Der Leitfaden sagt: „Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant“ (Deklariere eine öffentliche Formularansicht; die Submit-Route leitet aus dieser Deklaration eine enge Autorisierung ab. Kein `guest`-Profil, keine breite Berechtigung). Das CRM-Beispiel liefert ein `GuestPortalProfile` mit — einen Berechtigungssatz namens `guest_portal` mit `allowCreate: true` auf `crm_lead` und allen anderen Bits auf false — und der Kommentar über dem Formular sagt, dass „`sharing.allowAnonymous` den öffentlichen Endpunkt öffnet; das `guest_portal`-Profil (nur INSERT auf crm_lead) autorisiert den Schreibvorgang“. Der Quellcode der Runtime selbst trägt in 17.7.0 beide Geschichten wenige hundert Zeilen auseinander: Der Docstring der Route sagt, Sicherheit sei „an den `guest_portal`-Berechtigungssatz auf dem Ausführungskontext delegiert“, und warnt, ohne einen solchen „falle die Middleware offen“; der Handler-Code darunter sagt, öffentliche Formulare „work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal`“ (funktionieren unter secure-by-default (anonymous-deny) OHNE ein in der Installation konfiguriertes `guest_portal`) und behält den Profilnamen im Kontext nur „for back-compat with object hooks“ (zur Rückwärtskompatibilität mit Objekt-Hooks). + +Also habe ich `GuestPortalProfile` aus den `permissions` des Stacks entfernt und neu gebaut. Das Erste, was geschah: Der Build verweigerte sich: + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +Dieses Gate ist ein eigenes Thema; hier ist es ein beruhigender Nebensatz — ein verschwindender Berechtigungssatz gilt als reviewpflichtige Änderung, selbst wenn sich herausstellt, dass der Satz nichts tut. Ich habe die Drift akzeptiert, geprüft, dass das kompilierte Artefakt einen statt zwei Berechtigungssätze listet, und die vier Sonden erneut laufen lassen. + +| Sonde | Mit `guest_portal` | Ohne `guest_portal` | +|:--|:--|:--| +| `POST …/submit` mit `name`, `email` | `201`, Zeile angelegt | `201`, Zeile angelegt | +| `POST …/submit` mit eingeschmuggeltem `status`, `owner_id`, `organization_id` | `201`; `status: "new"`, `owner_id: null`, `organization_id` des Servers | identisch | +| anonymes `GET /api/v1/data/crm_lead` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| anonymes `GET` der angelegten Zeile | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +Identisch, Zeile für Zeile. Auf 17.7.0 trägt die Deklaration, und das Profil ist auf diesem Pfad wirkungslos. Praktisch heißt das: Füge kein Gastprofil hinzu, damit ein öffentliches Formular funktioniert. Es setzt eine stehende Berechtigung in deine Zugriffsmatrix, die die öffentliche Tür nie konsultiert, und es erzählt dem nächsten Reviewer — Mensch oder Agent — eine Geschichte darüber, wo die Grenze liegt, die die Runtime nicht teilt. Der Kommentar des Beispiels ist für 17.7.0 falsch, und ich habe ihn als solchen gemeldet; bis er sich ändert, lies den Leitfaden, nicht den Kommentar. + +An einer weiteren Stelle gehen Beispiel und Leitfaden auseinander: Der dritte Schritt des Leitfadens lautet „Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation“ (Setze `sharingModel: 'private'` auf dem Objekt, damit Einsendungen nach der Anlage auf Mitarbeiter beschränkt sind), und das Lead-Objekt des CRM ist absichtlich `public_read_write`, mit einem Kommentar, der erklärt, dass die Demo bewusst organisationsweit geteilt ist. Das schwächt die Tür nicht — der organisationsweite Standard regelt, was *angemeldete* Nutzer voneinander sehen, und der anonyme Aufrufer wird abgewiesen, bevor irgendetwas davon konsultiert wird — aber für das eigene Aufnahmeobjekt ist der Rat befolgenswert. + +## Was diese Tür nicht tut + +- **Keine Ratenbegrenzung, kein Captcha, keine Spam-Abwehr.** Für den öffentlichen Formularpfad ist nichts davon dokumentiert, und ich habe nichts gefunden: Die Sonden dieses Beitrags haben ohne Anmeldedaten zehn Leads angelegt, und nichts in der Runtime hat sie gezählt oder gebremst. Setze den üblichen Edge-Schutz vor `/api/v1/forms/*`; die Runtime bewacht, was geschrieben wird, nicht wie oft. +- **Das `required` des Formulars wird beim öffentlichen Absenden nicht erzwungen.** Das Web-to-Lead-Formular markiert `email` als Pflichtfeld; ein `POST` ohne es antwortete `201` mit `email: null`. Das `required` des Objekts wird erzwungen — ein `POST` ohne `name` antwortete `400 VALIDATION_FAILED` mit „Lead Name is required“. Wenn ein Feld vorhanden sein muss, verlange es auf dem Objekt oder prüfe es in einem Hook; das Formular-Flag ist auf diesem Pfad ein Darstellungshinweis. +- **Die `201` gibt die ganze Zeile zurück.** Das Zurücklesen, das die Berechtigung erlaubt, ist die vom Besucher angelegte Zeile, einschließlich servergestempelter Werte und Standardwerte — `organization_id` darunter. In meinem Fall war nichts Sensibles dabei. Trägt dein Aufnahmeobjekt interne Felder mit aussagekräftigen Standardwerten, geh davon aus, dass der Absender sie einmal sieht. Dasselbe gilt für alles, was ein `beforeInsert`- oder `afterInsert`-Hook auf dem Zielobjekt stempelt, einschließlich Werten, die er aus vorhandenen Datensätzen ableitet. Halte auf 17.7.0 Hooks, die vorhandene Datensätze nachschlagen — Deduplizierung, Round-Robin-Zuweisung —, aus dem Einfügepfad eines öffentlichen Aufnahmeobjekts heraus und erledige dieses Routing nach der Aufnahme, in einem Flow oder einem geplanten Job. +- **Der Eigentümer ist niemand.** `owner_id` bleibt bei einer öffentlichen Einsendung `null`; das Security-Plugin überlässt es „for object hooks / the first-admin bootstrap to assign“ (Objekt-Hooks oder dem Bootstrap des ersten Admins zur Zuweisung). Wenn deine Pipeline Leads zuweist, tu es in einem Hook oder Flow — erwarte nicht, dass die Zeile einer Person gehört. +- **Ein Rückzug braucht einen Rebuild — und `os dev` sagt dir, wenn du ihn vergessen hast.** Mein erster Versuch bei Schritt fünf änderte nichts, weil `objectstack dev --no-watch` das vorherige `dist/objectstack.json` auslieferte und das in seiner fünften Logzeile auch sagte: „dist/objectstack.json is OLDER than your sources — this boot serves the STALE build“ (dist/objectstack.json ist ÄLTER als deine Quellen — dieser Start liefert den VERALTETEN Build aus). Die ausgegebene Abhilfe funktionierte. Ich erwähne es, weil eine agentengetriebene Schleife, die nur Statuscodes liest, geschlossen hätte, der Schalter funktioniere nicht. +- **Rückzug über Metadatenebenen hinweg hat Kanten, die dieser Beitrag nicht abgeschritten ist.** Die gespeicherte Kopie einer Ansicht in einer Organisation und von Paketen ausgelieferte Formulare folgen im Leitfaden eigenen Regeln. Der hier getestete einzelne Schalter ist der, den die meisten Apps brauchen. + +## Fünf Fragen eines Reviewers + +Ob das Formular von einem Menschen oder einem Agenten geschrieben wurde, das Review ist dasselbe — und kurz, weil die Berechtigung das Formular ist. + +1. **Welche Formulare sind öffentlich?** Jede Formularansicht, bei der `sharing.enabled`, `sharing.allowAnonymous` und ein `publicLink` gesetzt sind, ist ein anonymer Endpunkt. Das kompilierte Artefakt listet sie; woanders muss man nicht suchen. +2. **Was steht in `sections[].fields`?** Diese Liste, abzüglich der zwölf serververwalteten Namen, ist die öffentliche Schreibfläche. Lies sie wie eine Firewall-Regel. +3. **Füllt das Objekt aus, was das Formular auslässt?** Jedes Pflichtfeld, das das Formular nicht erhebt, braucht einen Standardwert auf Objektebene — `status` im Beispiel — sonst schlägt die Einsendung fehl. Und jedes Feld, das vorhanden sein muss, braucht sein `required` auf dem Objekt, nicht auf dem Formular. +4. **Gibt es irgendwo eine stehende anonyme Berechtigung?** Ein Berechtigungssatz, der an eine `guest`- oder `everyone`-Position gebunden ist, oder ein `guest_portal`-Satz, der „damit das Formular funktioniert“ hinzugefügt wurde, ist nicht nötig und weitet nichts aus, was die Tür benutzt. Behandle seine Anwesenheit als Frage. +5. **Was steht vor der Tür?** Ratenbegrenzung und Bot-Schutz sind Aufgabe des Deployments. + +Das ist dasselbe Argument über den Ort der Durchsetzung wie in [Wo Agenten-Berechtigungen durchgesetzt werden](/en/blog/objectos-agent-permission-boundaries/), angewandt auf einen Aufrufer ganz ohne Identität: Die Prüfung läuft in der Route und der Datenschicht, nicht in der UI, die die Felder zufällig verbirgt. Und es ist die Antwort auf das Review-Problem, das [der Lovable-Beitrag](/de/blog/is-lovable-safe-for-production/) beschreibt — der verantwortliche Eigentümer kann die fünfzeilige Feldliste lesen und freigeben, was für eine generierte Policy nicht gilt. Der Glossar-Eintrag zum [Berechtigungsmodell](/en/glossary/permission-model/) beschreibt die allgemeine Form; diese Tür ist die eine bewusste Ausnahme von „deny by default“, und sie hat die Form des Formulars. + +## Richte deinen Agenten darauf aus + +Wenn ein Agent deine App schreibt, gib ihm nicht diesen Artikel, sondern die Regel: Ein öffentliches Formular ist ein `formViews`-Eintrag mit einem `sharing`-Block, die Feldliste ist die Grenze, das Objekt besitzt Standardwerte und Pflichtfelder, und kein Berechtigungssatz ist beteiligt. Der [Leitfaden zur Konfiguration eines Coding-Agenten für ObjectStack](/en/blog/how-to-point-your-agent-at-objectstack/) beschreibt, wie diese Regel zum Agenten gelangt — die Regeldatei, der MCP-Endpunkt, den eine laufende App bereitstellt, und die Spec. `npm create objectstack@latest` installiert 17.7.0, die Version, auf der dieses Protokoll lief. + +Dieselbe Schleife gehostet, im Browser, ohne Installation? Das ist ObjectOS, die kommerzielle Laufzeitumgebung auf Basis von ObjectStack. diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.es.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.es.mdx new file mode 100644 index 0000000..caf3a03 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.es.mdx @@ -0,0 +1,211 @@ +--- +title: "¿Cómo dejar que visitantes anónimos envíen un formulario a una app de negocio gobernada? Una puerta pública, cinco campos de ancho" +description: "Desde v17 el acceso anónimo a datos siempre se deniega. La única puerta abierta es una vista de formulario pública: el permiso sale del propio formulario — crear una fila, releerla, cinco campos, nada más. Medido en 17.7.0." +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover-en.svg +tags: + - Formularios públicos + - Web-to-Lead + - Acceso anónimo +--- + +**La versión corta.** Desde v17, una petición no autenticada de datos de objeto en una app ObjectStack se rechaza con `401 UNAUTHENTICATED` — cada objeto, cada endpoint, sin interruptor de exclusión. La única puerta que una app gobernada deja abierta al público es una **vista de formulario pública**: declaras un formulario, marcas su bloque `sharing` con `allowAnonymous: true`, y el runtime deriva toda la autorización del visitante de esa declaración — crear un registro en ese único objeto, leer la fila que acaba de crear, nada más. La lista de campos del formulario es la frontera de seguridad. El formulario web-to-lead del ejemplo CRM lista cinco campos, así que pueden llegar cinco campos; los otros ocho campos de negocio y todas las columnas de sistema no pueden, envíe lo que envíe el visitante. Lo llamaré un **permiso con forma de formulario** (form-shaped grant): autorización calculada a partir del formulario en el momento de la petición, y por eso imposible de configurar más ancha que el formulario. Todo lo que sigue se midió en la `@objectstack/cli` 17.7.0 publicada, la versión que `npm create objectstack@latest` instala hoy — incluida la única cuestión en la que la documentación y el ejemplo incluido discrepan: si hace falta un perfil `guest_portal`. No hace falta. + +## El problema, en palabras de la propia documentación + +La guía de ObjectStack sobre recogida pública de datos abre con el escenario tal como lo plantearía un usuario: + +> Quiero que visitantes anónimos envíen un formulario — «Contacto», «Solicitar una demo», un formulario de admisión — que cree un registro. Pero **no** debo darles acceso a la base, y solo deben poder establecer los campos del formulario. + +Toda app de negocio tiene uno: el formulario de leads en la web, la admisión de soporte, la solicitud de demo, la reclamación de garantía. Las respuestas habituales cuestan algo cada una. Un rol «invitado» con permiso de escritura sobre la tabla es un permiso permanente que nadie vuelve a leer, y deriva. Un endpoint escrito a mano se salta el modelo de permisos que gobierna todo lo demás. Una herramienta de formularios de terceros con un webhook significa que ahora dos sistemas tienen el esquema, y uno de ellos no es el que miran tus revisores. + +Desde v17 la primera respuesta ni siquiera está disponible. La documentación de permisos registra el motivo de pasada: «since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired» (desde v17 el acceso anónimo a datos de objeto se deniega siempre de todos modos — la exclusión `requireAuth: false` se retiró). La [guía de autoalojamiento](/en/blog/how-to-self-host-objectstack/) convierte eso en un paso de verificación — hacer `curl` a un endpoint de objeto sin credenciales e insistir en el `401` con un `code` legible por máquina — y trata un `200` ahí como un incidente. Así que la forma honesta de la pregunta es: si el plano de datos está cerrado a los llamadores anónimos por construcción, ¿cómo pasa un formulario público? + +## Un formulario público es una declaración, no un permiso + +Este es el formulario web-to-lead completo del ejemplo CRM, tal como se publica en la etiqueta 17.7.0: + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +Nada en él menciona un permiso. El bloque `sharing` es lo que abre la puerta: con `enabled`, `allowAnonymous` y un `publicLink` establecidos, el runtime registra dos endpoints anónimos — `GET /api/v1/forms/contact-us`, que devuelve el formulario resuelto y el esquema filtrado, y `POST /api/v1/forms/contact-us/submit`, que inserta un `crm_lead`. La regla de una línea de la documentación sobre lo que el visitante puede escribir es «Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side» (solo se aceptan al enviar los campos listados en `sections[].fields`; todo lo demás se elimina en el servidor). Y la razón que da para construirlo así es la frase que este artículo existe para comprobar: «Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form» (la autorización se deriva de la declaración, no se configura por separado — así que el permiso no puede derivar más ancho que el formulario). + +![Dos redes: la ruta conserva solo los cinco campos declarados y descarta los nombres gestionados por el servidor; la capa de datos admite solo inserción y relectura en crm_lead y elimina los mismos nombres de nuevo. Las lecturas responden 401; un formulario retirado responde 404.](./two-nets-en.svg) + +Mecánicamente hay dos redes, y merece la pena nombrarlas porque un revisor puede comprobar cada una. + +**La primera red es la ruta.** El manejador de envío construye su lista de permitidos a partir de `sections[].fields` del formulario que coincide y copia solo esas claves del cuerpo de la petición. Antes, descarta por nombre toda clave de `PUBLIC_FORM_SERVER_MANAGED_FIELDS`, un conjunto que exporta el paquete spec: `id`, `owner_id`, `owning_business_unit_id`, `organization_id`, `tenant_id`, `created_at`, `created_by`, `updated_at`, `updated_by`, `is_deleted`, `deleted_at` y `__search` — doce nombres que «**never** accepted from a public submission — not even when a section declares them» (nunca se aceptan de un envío público — ni siquiera cuando una sección los declara). Un formulario sin ningún campo se rechaza con `400` en lugar de aceptar lo que llegue. + +**La segunda red es la capa de datos.** La ruta adjunta a la escritura un `publicFormGrant: { object: 'crm_lead' }` limitado a la petición, y el plugin de seguridad honra exactamente eso: `insert`, más `find`, `findOne` y `count` para la relectura, solo en ese objeto. Antes de admitir la inserción elimina los mismos doce nombres por segunda vez, de modo que incluso si un formulario declarase `owner_id` en una sección, o el filtro de la ruta y la capa de datos se editaran algún día por separado, un visitante no podría falsificar la propiedad ni la ubicación de inquilino. El permiso es la forma del formulario, leída en el momento de la petición. No hay ninguna fila en una tabla de permisos que diga «anónimo puede escribir en leads», porque no hay nada que configurar. + +## La transcripción, en 17.7.0 + +Tomé `examples/app-crm` de la etiqueta `@objectstack/cli@17.7.0`, fijé sus dependencias a los paquetes 17.7.0 publicados y lo arranqué con `objectstack dev`. El servidor de desarrollo siembra una cuenta de administrador en una base de datos vacía; los cinco leads sembrados le pertenecen. Cada respuesta de abajo es real y está recortada solo por longitud. + +**1. La puerta se describe a sí misma — cinco campos de ancho.** Sin credenciales: + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +El `objectSchema` lleva exactamente los cinco campos permitidos. `crm_lead` declara trece campos de negocio en su definición de objeto; el esquema público muestra cinco y no dice nada de `status`, `lead_score`, `assigned_to`, `account`, `notes` ni del resto. El visitante ni siquiera puede saber qué intentar. + +**2. Un envío limpio crea una fila.** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"` no se envió; es el valor por defecto de escritura del objeto, que el objeto lead declara precisamente «so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required`» (para que las creaciones mínimas — p. ej. el formulario público Web-to-Lead, que no expone status — satisfagan `required`). `source: "web"` y `lead_score: 0` también son valores por defecto. + +**3. Un envío hostil crea la misma fila.** Seis claves enviadas; dos están en el formulario: + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +Leída de nuevo como administrador, la fila almacenada es la misma: los dos campos permitidos llegaron, los dos campos de negocio que no están en el formulario se descartaron, y los dos anclajes de sistema se descartaron y luego los selló el servidor — `organization_id` es la organización del propio despliegue, no `org_other`, y `owner_id` es `null`. El `201` no dice qué claves se rechazaron; la ruta las descarta sin comentario. + +| Clave enviada | ¿En el formulario? | Almacenada como | Por qué | +|:--|:--|:--|:--| +| `name`, `email` | sí | tal como se envió | permitida | +| `status: "converted"` | no | `"new"` | no está en `sections[].fields`; aplica el valor por defecto del objeto | +| `lead_score: 100` | no | `0` | no está en `sections[].fields`; aplica el valor por defecto del objeto | +| `owner_id: "attacker"` | no | `null` | nombre gestionado por el servidor, denegado en ambas redes | +| `organization_id: "org_other"` | no | el del propio servidor | nombre gestionado por el servidor; sellado por el runtime | + +**4. La puerta no se abre en sentido contrario.** El mismo cliente anónimo, ahora leyendo: + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +Ambas responden `401` — la lista y la fila individual que el visitante creó un segundo antes. El cuerpo del `201` es la única vez que quien envía ve ese registro. «The public path only ever **inserts**; it never lists» (la ruta pública solo inserta; nunca lista). + +**5. Retirar el formulario cierra ambos endpoints.** Mantén el `publicLink`, pon `enabled: false`, reconstruye, reinicia: + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +El recuento de leads leído como administrador se quedó en 15 antes y después. Cuatro códigos de estado cuentan toda la historia de la puerta: `201`, `201`, `401`, `404`. + +## ¿Qué historia soporta la carga: el perfil de invitado o la declaración? + +Aquí la documentación y el ejemplo incluido discrepan, y no quise tomar partido leyendo. La guía dice: «Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant» (declara una vista de formulario pública; la ruta de envío deriva una autorización estrecha de esa declaración. Sin perfil `guest`, sin permiso amplio). El ejemplo CRM incluye un `GuestPortalProfile` — un conjunto de permisos llamado `guest_portal` con `allowCreate: true` en `crm_lead` y todos los demás bits en false — y el comentario sobre el formulario dice que «`sharing.allowAnonymous` abre el endpoint público; el perfil `guest_portal` (solo INSERT en crm_lead) autoriza la escritura». El propio código fuente del runtime en 17.7.0 lleva ambas historias a unos cientos de líneas de distancia: el docstring de la ruta dice que la seguridad «se delega en el conjunto de permisos `guest_portal` que porta el contexto de ejecución» y advierte de que sin él el middleware «falls open» (queda abierto); el código del manejador, debajo, dice que los formularios públicos «work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal`» (funcionan bajo seguridad por defecto (denegación de anónimos) SIN un `guest_portal` configurado en el despliegue) y mantiene el nombre del perfil en el contexto solo «for back-compat with object hooks» (por compatibilidad con los hooks de objeto). + +Así que quité `GuestPortalProfile` de los `permissions` del stack y reconstruí. Lo primero que ocurrió es que la construcción se negó: + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +Esa puerta de control es un tema aparte; aquí es un inciso tranquilizador — la desaparición de un conjunto de permisos se trata como un cambio revisable incluso cuando resulta que el conjunto no hace nada. Acepté la deriva, confirmé que el artefacto compilado listaba un conjunto de permisos en vez de dos, y volví a ejecutar las cuatro sondas. + +| Sonda | Con `guest_portal` | Sin `guest_portal` | +|:--|:--|:--| +| `POST …/submit` con `name`, `email` | `201`, fila creada | `201`, fila creada | +| `POST …/submit` colando `status`, `owner_id`, `organization_id` | `201`; `status: "new"`, `owner_id: null`, `organization_id` del servidor | idéntico | +| `GET /api/v1/data/crm_lead` anónimo | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| `GET` anónimo de la fila creada | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +Idéntico, línea por línea. En 17.7.0 la declaración soporta la carga y el perfil es inerte en esta ruta. Lo que eso significa en la práctica: no añadas un perfil de invitado para que un formulario público funcione. Añade a tu matriz de acceso un permiso permanente que la puerta pública nunca consulta, y le cuenta al siguiente revisor — humano o agente — una historia sobre dónde está la frontera que el runtime no comparte. El comentario del ejemplo es incorrecto para 17.7.0, y lo he reportado como tal; hasta que cambie, lee la guía, no el comentario. + +Hay otro punto en el que el ejemplo y la guía se separan: el tercer paso de la guía es «Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation» (pon `sharingModel: 'private'` en el objeto para que los envíos queden limitados al personal tras su creación), y el objeto lead del CRM es deliberadamente `public_read_write`, con un comentario que explica que la demo se comparte a nivel de organización a propósito. Eso no debilita la puerta — el valor por defecto de toda la organización gobierna lo que los usuarios *autenticados* ven de las filas de los demás, y el llamador anónimo se rechaza antes de que nada de eso se consulte — pero es un consejo que merece la pena seguir en tu propio objeto de admisión. + +## Lo que esta puerta no hace + +- **Sin limitación de tasa, sin captcha, sin control de spam.** Nada de eso está documentado para la ruta de formularios públicos, y no encontré nada: las sondas de este artículo crearon diez leads sin credenciales, y nada en el runtime los contó ni los frenó. Pon la protección de borde habitual delante de `/api/v1/forms/*`; el runtime vigila qué se escribe, no con qué frecuencia. +- **El `required` del formulario no se aplica en el envío público.** El formulario web-to-lead marca `email` como obligatorio; un `POST` sin él respondió `201` con `email: null`. El `required` del objeto sí se aplica — un `POST` sin `name` respondió `400 VALIDATION_FAILED` con «Lead Name is required». Si un campo debe estar presente, exígelo en el objeto o compruébalo en un hook; el indicador del formulario es una pista de renderizado en esta ruta. +- **El `201` devuelve la fila entera.** La relectura que el permiso permite es la fila que el visitante creó, incluidos los valores sellados por el servidor y los valores por defecto — `organization_id` entre ellos. En mi caso no había nada sensible. Si tu objeto de admisión lleva campos internos con valores por defecto significativos, asume que quien envía los verá una vez. Lo mismo vale para todo lo que un hook `beforeInsert` o `afterInsert` del objeto destino sella, incluidos los valores que deriva de registros existentes. En 17.7.0, mantén los hooks que consultan registros existentes — deduplicación, asignación round-robin — fuera de la ruta de inserción de un objeto de admisión pública, y haz ese enrutamiento después de la admisión, en un flujo o en un trabajo programado. +- **El propietario es nadie.** `owner_id` se queda en `null` en un envío público; el plugin de seguridad lo deja «for object hooks / the first-admin bootstrap to assign» (para que lo asignen los hooks de objeto o el arranque del primer administrador). Si tu pipeline asigna leads, hazlo en un hook o un flujo — no esperes que la fila pertenezca a una persona. +- **Retirar requiere reconstruir — y `os dev` te lo dirá si lo olvidas.** Mi primer intento del paso cinco no cambió nada, porque `objectstack dev --no-watch` sirvió el `dist/objectstack.json` anterior y lo dijo en su quinta línea de registro: «dist/objectstack.json is OLDER than your sources — this boot serves the STALE build» (dist/objectstack.json es MÁS ANTIGUO que tus fuentes — este arranque sirve la construcción OBSOLETA). La solución que imprimió funcionó. Lo menciono porque un bucle dirigido por un agente que solo leyera códigos de estado habría concluido que el interruptor no funciona. +- **Retirar a través de capas de metadatos tiene bordes que este artículo no recorrió.** La copia guardada de una vista por una organización, y los formularios que publican los paquetes, siguen sus propias reglas en la guía. El único interruptor probado aquí es el que la mayoría de apps necesita. + +## Las cinco preguntas de un revisor + +Tanto si el formulario lo escribió una persona como un agente, la revisión es la misma, y es corta porque el permiso es el formulario. + +1. **¿Qué formularios son públicos?** Toda vista de formulario con `sharing.enabled`, `sharing.allowAnonymous` y un `publicLink` establecidos es un endpoint anónimo. El artefacto compilado los lista; no hay otro sitio donde mirar. +2. **¿Qué hay en `sections[].fields`?** Esa lista, menos los doce nombres gestionados por el servidor, es la superficie pública de escritura. Léela como leerías una regla de cortafuegos. +3. **¿Rellena el objeto lo que el formulario omite?** Cada campo obligatorio que el formulario no recoge necesita un valor por defecto a nivel de objeto — `status` en el ejemplo — o el envío falla. Y cada campo que debe estar presente necesita su `required` en el objeto, no en el formulario. +4. **¿Hay algún permiso anónimo permanente en alguna parte?** Un conjunto de permisos vinculado a una posición `guest` o `everyone`, o un conjunto `guest_portal` añadido «para que el formulario funcione», no es necesario y no amplía nada de lo que la puerta usa. Trata su presencia como una pregunta. +5. **¿Qué hay delante de la puerta?** La limitación de tasa y la protección contra bots son tarea del despliegue. + +Es el mismo argumento sobre el lugar de aplicación que [dónde se aplican los permisos de los agentes](/en/blog/objectos-agent-permission-boundaries/), aplicado a un llamador sin identidad alguna: la comprobación corre en la ruta y en la capa de datos, no en la UI que casualmente oculta los campos. Y es la respuesta al problema de revisión que describe [el artículo sobre Lovable](/es/blog/is-lovable-safe-for-production/) — el responsable puede leer la lista de campos de cinco líneas y aprobarla, lo que no es cierto de una política generada. La entrada del glosario sobre el [modelo de permisos](/en/glossary/permission-model/) tiene la forma general; esta puerta es la única excepción deliberada a «denegar por defecto», y tiene la forma del formulario. + +## Apunta tu agente hacia ello + +Si un agente escribe tu app, lo que hay que darle no es este artículo sino la regla: un formulario público es una entrada de `formViews` con un bloque `sharing`, la lista de campos es la frontera, el objeto posee los valores por defecto y la obligatoriedad, y no interviene ningún conjunto de permisos. La [guía para configurar un agente de código para ObjectStack](/en/blog/how-to-point-your-agent-at-objectstack/) cubre cómo esa regla llega al agente — el archivo de reglas, el endpoint MCP que expone una app en ejecución y la spec. `npm create objectstack@latest` instala 17.7.0, la versión en la que corrió esta transcripción. + +¿Quieres el mismo ciclo alojado, en el navegador, sin instalar nada? Eso es ObjectOS, el entorno de ejecución comercial construido sobre ObjectStack. diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.fr.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.fr.mdx new file mode 100644 index 0000000..fc876cd --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.fr.mdx @@ -0,0 +1,211 @@ +--- +title: "Comment laisser des visiteurs anonymes soumettre un formulaire dans une application métier gouvernée ? Une porte publique, large de cinq champs" +description: "Depuis la v17, l’accès anonyme aux données est toujours refusé. La seule porte ouverte est une vue de formulaire publique : le droit vient du formulaire — créer une ligne, la relire, cinq champs, rien d’autre. Mesuré sur 17.7.0." +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover-en.svg +tags: + - Formulaires publics + - Web-to-Lead + - Accès anonyme +--- + +**En bref.** Depuis la v17, une requête non authentifiée sur des données d’objet dans une application ObjectStack est refusée avec `401 UNAUTHENTICATED` — chaque objet, chaque point d’accès, sans option de désactivation. La seule porte qu’une application gouvernée laisse ouverte au public est une **vue de formulaire publique** : on déclare un formulaire, on marque son bloc `sharing` avec `allowAnonymous: true`, et le runtime dérive toute l’autorisation du visiteur de cette déclaration — créer un enregistrement sur cet unique objet, relire la ligne qu’il vient de créer, rien d’autre. La liste des champs du formulaire est la frontière de sécurité. Le formulaire web-to-lead de l’exemple CRM liste cinq champs, donc cinq champs peuvent arriver ; les huit autres champs métier et toutes les colonnes système ne le peuvent pas, quoi que le visiteur envoie. J’appellerai cela un **droit à la forme du formulaire** (form-shaped grant) : une autorisation calculée à partir du formulaire au moment de la requête, et qui ne peut donc pas être configurée plus large que le formulaire. Tout ce qui suit a été mesuré sur la `@objectstack/cli` 17.7.0 publiée, la version que `npm create objectstack@latest` installe aujourd’hui — y compris la seule question sur laquelle la documentation et l’exemple livré se contredisent : faut-il un profil `guest_portal` ? Non. + +## Le problème, dans les mots de la documentation + +Le guide ObjectStack sur la collecte publique de données s’ouvre sur le scénario tel qu’un utilisateur le formulerait : + +> Je veux que des visiteurs anonymes soumettent un formulaire — « Contactez-nous », « Demander une démo », un formulaire d’admission — qui crée un enregistrement. Mais je ne dois **pas** leur donner accès à la base, et ils ne doivent pouvoir renseigner que les champs du formulaire. + +Toute application métier en a un : le formulaire de leads sur le site, l’admission du support, la demande de démo, la réclamation de garantie. Les réponses habituelles ont chacune un coût. Un rôle « invité » avec droit d’écriture sur la table est un droit permanent que personne ne relit, et il dérive. Un point d’accès écrit à la main contourne le modèle de permissions qui gouverne tout le reste. Un outil de formulaires tiers avec un webhook signifie que deux systèmes détiennent désormais le schéma, et l’un d’eux n’est pas celui que vos relecteurs regardent. + +Depuis la v17, la première réponse n’est même plus disponible. La documentation des permissions en note la raison au passage : « since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired » (depuis la v17, l’accès anonyme aux données d’objet est de toute façon toujours refusé — l’option `requireAuth: false` a été retirée). Le [guide d’auto-hébergement](/en/blog/how-to-self-host-objectstack/) en fait une étape de vérification — faire un `curl` sur un point d’accès d’objet sans identifiants et exiger le `401` avec un `code` lisible par machine — et traite un `200` à cet endroit comme un incident. La forme honnête de la question est donc : si le plan de données est fermé aux appelants anonymes par construction, comment un formulaire public passe-t-il ? + +## Un formulaire public est une déclaration, pas une permission + +Voici l’intégralité du formulaire web-to-lead de l’exemple CRM, tel qu’il est livré au tag 17.7.0 : + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +Rien n’y mentionne une permission. C’est le bloc `sharing` qui ouvre la porte : avec `enabled`, `allowAnonymous` et un `publicLink` définis, le runtime enregistre deux points d’accès anonymes — `GET /api/v1/forms/contact-us`, qui renvoie le formulaire résolu et le schéma filtré, et `POST /api/v1/forms/contact-us/submit`, qui insère un `crm_lead`. La règle d’une ligne de la documentation sur ce que le visiteur peut écrire est : « Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side » (seuls les champs listés dans `sections[].fields` sont acceptés à la soumission ; tout le reste est retiré côté serveur). Et la raison qu’elle donne de l’avoir construit ainsi est la phrase que cet article existe pour vérifier : « Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form » (l’autorisation est dérivée de la déclaration, pas configurée séparément — le droit ne peut donc pas dériver plus large que le formulaire). + +![Deux filets : la route ne garde que les cinq champs déclarés et rejette les noms gérés par le serveur ; la couche de données n’admet que l’insertion et la relecture sur crm_lead et retire à nouveau les mêmes noms. Les lectures répondent 401 ; un formulaire retiré répond 404.](./two-nets-en.svg) + +Mécaniquement, il y a deux filets, et ils méritent un nom parce qu’un relecteur peut vérifier chacun. + +**Le premier filet est la route.** Le gestionnaire de soumission construit sa liste d’autorisation à partir de `sections[].fields` du formulaire trouvé et ne copie que ces clés depuis le corps de la requête. Avant cela, il rejette par nom chaque clé de `PUBLIC_FORM_SERVER_MANAGED_FIELDS`, un ensemble exporté par le paquet spec : `id`, `owner_id`, `owning_business_unit_id`, `organization_id`, `tenant_id`, `created_at`, `created_by`, `updated_at`, `updated_by`, `is_deleted`, `deleted_at` et `__search` — douze noms qui ne sont « **never** accepted from a public submission — not even when a section declares them » (jamais acceptés d’une soumission publique — pas même quand une section les déclare). Un formulaire sans aucun champ est refusé avec `400` plutôt que d’accepter tout ce qui arrive. + +**Le second filet est la couche de données.** La route attache à l’écriture un `publicFormGrant: { object: 'crm_lead' }` limité à la requête, et le plugin de sécurité honore exactement cela : `insert`, plus `find`, `findOne` et `count` pour la relecture, sur cet objet seulement. Avant d’admettre l’insertion, il retire les mêmes douze noms une seconde fois, de sorte que même si un formulaire déclarait `owner_id` dans une section, ou si le filtre de la route et la couche de données étaient un jour modifiés séparément, un visiteur ne pourrait falsifier ni la propriété ni le rattachement au locataire. Le droit est la forme du formulaire, lue au moment de la requête. Il n’y a aucune ligne dans une table de permissions qui dise « anonyme peut écrire dans leads », parce qu’il n’y a rien à configurer. + +## La transcription, sur 17.7.0 + +J’ai pris `examples/app-crm` au tag `@objectstack/cli@17.7.0`, figé ses dépendances sur les paquets 17.7.0 publiés et lancé `objectstack dev`. Le serveur de développement crée un compte administrateur sur une base vide ; les cinq leads de démonstration lui appartiennent. Chaque réponse ci-dessous est réelle et n’est tronquée que pour la longueur. + +**1. La porte se décrit elle-même — large de cinq champs.** Sans identifiants : + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +Le `objectSchema` porte exactement les cinq champs autorisés. `crm_lead` déclare treize champs métier dans sa définition d’objet ; le schéma public en montre cinq et ne dit rien de `status`, `lead_score`, `assigned_to`, `account`, `notes` ni du reste. Le visiteur ne peut même pas savoir quoi essayer. + +**2. Une soumission propre crée une ligne.** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"` n’a pas été envoyé ; c’est la valeur par défaut d’écriture de l’objet, que l’objet lead déclare précisément « so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required` » (pour que les créations minimales — par exemple le formulaire public Web-to-Lead, qui n’expose pas status — satisfassent `required`). `source: "web"` et `lead_score: 0` sont aussi des valeurs par défaut. + +**3. Une soumission hostile crée la même ligne.** Six clés envoyées ; deux sont sur le formulaire : + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +Relue en tant qu’administrateur, la ligne stockée est la même : les deux champs autorisés sont arrivés, les deux champs métier absents du formulaire ont été rejetés, et les deux ancres système ont été rejetées puis estampillées par le serveur — `organization_id` est l’organisation du déploiement lui-même, pas `org_other`, et `owner_id` est `null`. Le `201` ne dit pas quelles clés ont été refusées ; la route les rejette sans commentaire. + +| Clé envoyée | Sur le formulaire ? | Stockée comme | Pourquoi | +|:--|:--|:--|:--| +| `name`, `email` | oui | telle qu’envoyée | autorisée | +| `status: "converted"` | non | `"new"` | absente de `sections[].fields` ; la valeur par défaut de l’objet s’applique | +| `lead_score: 100` | non | `0` | absente de `sections[].fields` ; la valeur par défaut de l’objet s’applique | +| `owner_id: "attacker"` | non | `null` | nom géré par le serveur, refusé dans les deux filets | +| `organization_id: "org_other"` | non | celle du serveur | nom géré par le serveur ; estampillé par le runtime | + +**4. La porte ne s’ouvre pas dans l’autre sens.** Le même client anonyme, qui lit cette fois : + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +Les deux répondent `401` — la liste, et la ligne unique que le visiteur a créée une seconde plus tôt. Le corps du `201` est la seule fois où l’émetteur voit cet enregistrement. « The public path only ever **inserts**; it never lists » (le chemin public ne fait qu’insérer ; il ne liste jamais). + +**5. Retirer le formulaire ferme les deux points d’accès.** Garder le `publicLink`, mettre `enabled: false`, reconstruire, redémarrer : + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +Le nombre de leads lu en tant qu’administrateur est resté à 15 avant comme après. Quatre codes d’état racontent toute l’histoire de la porte : `201`, `201`, `401`, `404`. + +## Quelle histoire porte la charge : le profil invité ou la déclaration ? + +Ici la documentation et l’exemple livré se contredisent, et je ne voulais pas prendre parti en lisant. Le guide dit : « Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant » (déclarez une vue de formulaire publique ; la route de soumission dérive une autorisation étroite de cette déclaration. Pas de profil `guest`, pas de droit large). L’exemple CRM livre un `GuestPortalProfile` — un jeu de permissions nommé `guest_portal` avec `allowCreate: true` sur `crm_lead` et tous les autres bits à false — et le commentaire au-dessus du formulaire dit que « `sharing.allowAnonymous` ouvre le point d’accès public ; le profil `guest_portal` (INSERT seulement sur crm_lead) autorise l’écriture ». Le code source du runtime lui-même, en 17.7.0, porte les deux histoires à quelques centaines de lignes d’écart : la docstring de la route dit que la sécurité « est déléguée au jeu de permissions `guest_portal` porté par le contexte d’exécution » et avertit que sans lui le middleware « falls open » (reste ouvert) ; le code du gestionnaire, en dessous, dit que les formulaires publics « work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal` » (fonctionnent en sécurité par défaut (refus des anonymes) SANS `guest_portal` configuré dans le déploiement) et ne garde le nom du profil dans le contexte que « for back-compat with object hooks » (pour la rétrocompatibilité avec les hooks d’objet). + +J’ai donc retiré `GuestPortalProfile` des `permissions` du stack et reconstruit. La première chose qui s’est produite : la construction a refusé : + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +Cette barrière est un sujet à part entière ; ici c’est un aparté rassurant — la disparition d’un jeu de permissions est traitée comme un changement à relire, même quand le jeu s’avère ne rien faire. J’ai accepté la dérive, vérifié que l’artefact compilé listait un jeu de permissions au lieu de deux, et relancé les quatre sondes. + +| Sonde | Avec `guest_portal` | Sans `guest_portal` | +|:--|:--|:--| +| `POST …/submit` avec `name`, `email` | `201`, ligne créée | `201`, ligne créée | +| `POST …/submit` en glissant `status`, `owner_id`, `organization_id` | `201` ; `status: "new"`, `owner_id: null`, `organization_id` du serveur | identique | +| `GET /api/v1/data/crm_lead` anonyme | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| `GET` anonyme de la ligne créée | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +Identique, ligne pour ligne. Sur 17.7.0, c’est la déclaration qui porte la charge et le profil est inerte sur ce chemin. Ce que cela signifie en pratique : n’ajoutez pas de profil invité pour faire fonctionner un formulaire public. Il ajoute à votre matrice d’accès un droit permanent que la porte publique ne consulte jamais, et il raconte au prochain relecteur — humain ou agent — une histoire sur l’emplacement de la frontière que le runtime ne partage pas. Le commentaire de l’exemple est faux pour 17.7.0, et je l’ai signalé comme tel ; en attendant qu’il change, lisez le guide, pas le commentaire. + +Un autre endroit où l’exemple et le guide divergent : la troisième étape du guide est « Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation » (mettez `sharingModel: 'private'` sur l’objet pour que les soumissions soient réservées au personnel après création), et l’objet lead du CRM est délibérément `public_read_write`, avec un commentaire expliquant que la démo est volontairement partagée à l’échelle de l’organisation. Cela n’affaiblit pas la porte — la valeur par défaut à l’échelle de l’organisation gouverne ce que les utilisateurs *connectés* voient des lignes les uns des autres, et l’appelant anonyme est refusé avant que rien de cela ne soit consulté — mais c’est un conseil qui vaut d’être suivi sur votre propre objet d’admission. + +## Ce que cette porte ne fait pas + +- **Pas de limitation de débit, pas de captcha, pas de contrôle anti-spam.** Rien de cela n’est documenté pour le chemin des formulaires publics, et je n’ai rien trouvé : les sondes de cet article ont créé dix leads sans identifiants, et rien dans le runtime ne les a comptées ni ralenties. Placez la protection habituelle en bordure devant `/api/v1/forms/*` ; le runtime garde ce qui est écrit, pas la fréquence. +- **Le `required` du formulaire n’est pas appliqué à la soumission publique.** Le formulaire web-to-lead marque `email` comme obligatoire ; un `POST` sans lui a répondu `201` avec `email: null`. Le `required` de l’objet, lui, est appliqué — un `POST` sans `name` a répondu `400 VALIDATION_FAILED` avec « Lead Name is required ». Si un champ doit être présent, exigez-le sur l’objet ou vérifiez-le dans un hook ; sur ce chemin, l’indicateur du formulaire est une indication de rendu. +- **Le `201` renvoie la ligne entière.** La relecture que le droit autorise est la ligne que le visiteur a créée, y compris les valeurs estampillées par le serveur et les valeurs par défaut — `organization_id` parmi elles. Dans mon cas, rien de sensible. Si votre objet d’admission porte des champs internes aux valeurs par défaut significatives, partez du principe que l’émetteur les verra une fois. Il en va de même pour tout ce qu’un hook `beforeInsert` ou `afterInsert` de l’objet cible estampille, y compris les valeurs qu’il dérive d’enregistrements existants. Sur 17.7.0, gardez les hooks qui consultent des enregistrements existants — dédoublonnage, attribution en tourniquet — hors du chemin d’insertion d’un objet d’admission publique, et faites ce routage après l’admission, dans un flux ou une tâche planifiée. +- **Le propriétaire n’est personne.** `owner_id` reste `null` sur une soumission publique ; le plugin de sécurité le laisse « for object hooks / the first-admin bootstrap to assign » (aux hooks d’objet ou à l’amorçage du premier administrateur pour l’attribuer). Si votre pipeline attribue les leads, faites-le dans un hook ou un flux — n’attendez pas que la ligne appartienne à quelqu’un. +- **Un retrait demande une reconstruction — et `os dev` vous le dit si vous l’avez oubliée.** Ma première tentative à l’étape cinq n’a rien changé, parce que `objectstack dev --no-watch` a servi le `dist/objectstack.json` précédent et l’a dit à la cinquième ligne de son journal : « dist/objectstack.json is OLDER than your sources — this boot serves the STALE build » (dist/objectstack.json est PLUS ANCIEN que vos sources — ce démarrage sert la construction PÉRIMÉE). Le correctif affiché a fonctionné. Je le mentionne parce qu’une boucle pilotée par un agent qui ne lirait que les codes d’état aurait conclu que l’interrupteur ne fonctionne pas. +- **Le retrait à travers les couches de métadonnées a des bords que cet article n’a pas parcourus.** La copie d’une vue enregistrée par une organisation, et les formulaires livrés par des paquets, suivent leurs propres règles dans le guide. L’unique interrupteur testé ici est celui dont la plupart des applications ont besoin. + +## Les cinq questions d’un relecteur + +Que le formulaire ait été écrit par une personne ou par un agent, la relecture est la même, et elle est courte parce que le droit est le formulaire. + +1. **Quels formulaires sont publics ?** Toute vue de formulaire avec `sharing.enabled`, `sharing.allowAnonymous` et un `publicLink` définis est un point d’accès anonyme. L’artefact compilé les liste ; il n’y a nulle part ailleurs où regarder. +2. **Qu’y a-t-il dans `sections[].fields` ?** Cette liste, moins les douze noms gérés par le serveur, est la surface d’écriture publique. Lisez-la comme vous liriez une règle de pare-feu. +3. **L’objet comble-t-il ce que le formulaire omet ?** Chaque champ obligatoire que le formulaire ne collecte pas a besoin d’une valeur par défaut au niveau de l’objet — `status` dans l’exemple — sinon la soumission échoue. Et chaque champ qui doit être présent a besoin de son `required` sur l’objet, pas sur le formulaire. +4. **Y a-t-il quelque part un droit anonyme permanent ?** Un jeu de permissions lié à une position `guest` ou `everyone`, ou un jeu `guest_portal` ajouté « pour que le formulaire fonctionne », n’est pas nécessaire et n’élargit rien de ce que la porte utilise. Traitez sa présence comme une question. +5. **Qu’y a-t-il devant la porte ?** La limitation de débit et la protection contre les robots sont le travail du déploiement. + +C’est le même argument sur le lieu d’application que [où les permissions des agents sont appliquées](/en/blog/objectos-agent-permission-boundaries/), appliqué à un appelant sans aucune identité : la vérification s’exécute dans la route et la couche de données, pas dans l’interface qui se trouve cacher les champs. Et c’est la réponse au problème de relecture que décrit [l’article sur Lovable](/fr/blog/is-lovable-safe-for-production/) — le responsable peut lire la liste de champs de cinq lignes et l’approuver, ce qui n’est pas vrai d’une politique générée. L’entrée du glossaire sur le [modèle de permissions](/en/glossary/permission-model/) donne la forme générale ; cette porte est la seule exception délibérée au « refus par défaut », et elle a la forme du formulaire. + +## Pointez votre agent dessus + +Si un agent écrit votre application, ce qu’il faut lui transmettre n’est pas cet article mais la règle : un formulaire public est une entrée de `formViews` avec un bloc `sharing`, la liste des champs est la frontière, l’objet possède les valeurs par défaut et le caractère obligatoire, et aucun jeu de permissions n’intervient. Le [guide de configuration d’un agent de code pour ObjectStack](/en/blog/how-to-point-your-agent-at-objectstack/) couvre la façon dont cette règle parvient à l’agent — le fichier de règles, le point d’accès MCP qu’expose une application en cours d’exécution, et la spec. `npm create objectstack@latest` installe 17.7.0, la version sur laquelle cette transcription a tourné. + +Vous voulez la même boucle, hébergée, dans le navigateur, sans rien installer ? C’est ObjectOS, l’environnement d’exécution commercial construit sur ObjectStack. diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.ja.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.ja.mdx new file mode 100644 index 0000000..cff5aea --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.ja.mdx @@ -0,0 +1,211 @@ +--- +title: "匿名の訪問者にフォームを送信させて、統制された業務アプリに取り込むには?公開の扉はひとつ、幅はフィールド五つ" +description: "v17 以降、匿名のデータアクセスは常に拒否される。唯一開いている扉は公開フォームビューだ。ランタイムはフォームそのものから権限を導出する——一行を作り、その一行を読み返す、フィールドは五つ、それ以外は何もない。17.7.0 で実測。" +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover-en.svg +tags: + - 公開フォーム + - Web-to-Lead + - 匿名アクセス +--- + +**要点**:v17 以降、ObjectStack アプリでは認証されていないオブジェクトデータへのリクエストは `401 UNAUTHENTICATED` で拒否される——どのオブジェクトでも、どのエンドポイントでも、例外のスイッチはない。統制されたアプリが公衆に向けて開けておく唯一の扉が**公開フォームビュー**だ。フォームを宣言し、その `sharing` ブロックに `allowAnonymous: true` を付ければ、ランタイムはその宣言から訪問者の権限をまるごと導出する——そのひとつのオブジェクトにレコードを一件作り、いま作った行を読み返す。それ以外は何もできない。フォームのフィールド一覧が、そのままセキュリティ境界になる。CRM サンプルの web-to-lead フォームは五つのフィールドを列挙しているので、届くのは五つ。残り八つの業務フィールドとすべてのシステム列は、訪問者が何を送ろうと届かない。これを私は**フォームの形をした権限**(form-shaped grant)と呼ぶ。権限はリクエスト時にフォームから計算されるから、フォームより広く設定しようがない。以下はすべて、公開されている `@objectstack/cli` 17.7.0——今日 `npm create objectstack@latest` がインストールするバージョン——で実測した。ドキュメントと同梱サンプルが唯一食い違っている点、`guest_portal` プロファイルは必要なのか、も含めて。答えは「不要」だ。 + +## 問題を、ドキュメント自身の言葉で + +ObjectStack の公開データ収集ガイドは、利用者の言い回しでシナリオを書き出している: + +> 匿名の訪問者に「お問い合わせ」「デモ申込」「受付」といったフォームを送信させて、レコードを作らせたい。だが、土台のデータへのアクセスは**絶対に**与えてはならず、設定できるのはフォーム上のフィールドだけでなければならない。 + +業務アプリにはどれにもこういうフォームがある。サイトのリード獲得フォーム、サポート受付、デモ申込、保証申請。よくある答えにはそれぞれ代償がある。「guest」ロールにテーブルへの書き込み権を与えるのは、誰も読み返さない常設の権限で、漂流する。エンドポイントを手書きすれば、他のすべてを統制している権限モデルを迂回する。外部のフォームツールと webhook を使えば、スキーマを持つシステムが二つになり、そのうちひとつはレビュアーが見ていない方だ。 + +v17 以降、最初の答えはそもそも使えない。権限のドキュメントがその理由を一言で記録している:「since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired」(v17 以降、匿名のオブジェクトデータアクセスはどのみち常に拒否される——`requireAuth: false` によるオプトアウトは退役した)。[セルフホストの手順記事](/en/blog/how-to-self-host-objectstack/)はこれを検証ステップに変えている——資格情報なしでオブジェクトのエンドポイントに `curl` し、機械可読な `code` 付きの `401` を要求し、そこで `200` が返れば事故として扱う。だから問いを正直に言い直すとこうなる。データプレーンが構造的に匿名呼び出しに閉じているなら、公開フォームはどうやって通るのか? + +## 公開フォームは宣言であって、権限ではない + +これが CRM サンプルの web-to-lead フォームの全文で、17.7.0 タグで出荷されているままだ: + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +権限については一言もない。扉を開けるのは `sharing` ブロックだ。`enabled`、`allowAnonymous`、`publicLink` の三つが揃うと、ランタイムは匿名エンドポイントを二つ登録する——解決済みのフォームとホワイトリスト化されたスキーマを返す `GET /api/v1/forms/contact-us` と、`crm_lead` を一件挿入する `POST /api/v1/forms/contact-us/submit` だ。訪問者が書けるものについてのドキュメントの一行ルールは「Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side」(送信時に受け付けるのは `sections[].fields` に列挙されたフィールドだけで、それ以外はサーバー側で剥がされる)。そして、なぜこう作ったのかという理由こそ、この記事が検証するために存在する一文だ:「Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form」(権限は宣言から導出されるのであって、別に設定するのではない——だから権限がフォームより広く漂流することはない)。 + +![二つの網:ルートは宣言された五つのフィールドだけを残し、サーバー管理の名前を落とす。データ層は crm_lead への挿入と読み返しだけを許し、同じ名前をもう一度剥がす。読み取りは 401、撤回されたフォームは 404 を返す。](./two-nets-en.svg) + +仕組みとしては網が二つあり、それぞれに名前を付ける価値がある。レビュアーが一つずつ確認できるからだ。 + +**一つ目の網はルート。** 送信ハンドラーは、一致したフォームの `sections[].fields` から許可リストを組み立て、リクエストボディからそのキーだけを写し取る。その前に、spec パッケージが公開する集合 `PUBLIC_FORM_SERVER_MANAGED_FIELDS` に含まれるキー——`id`、`owner_id`、`owning_business_unit_id`、`organization_id`、`tenant_id`、`created_at`、`created_by`、`updated_at`、`updated_by`、`is_deleted`、`deleted_at`、`__search` の十二の名前——を名前で落とす。ドキュメントいわく、これらは「**never** accepted from a public submission — not even when a section declares them」(公開送信からは決して受け付けない——セクションが宣言していても)。フィールドがひとつもないフォームは、届いたものを何でも受け付けるのではなく `400` で拒否される。 + +**二つ目の網はデータ層。** ルートは書き込みに、リクエストスコープの `publicFormGrant: { object: 'crm_lead' }` を添える。セキュリティプラグインが認めるのは正確にそれだけ——`insert` と、読み返しのための `find`、`findOne`、`count`、しかもそのオブジェクトに限る。挿入を認める前に同じ十二の名前をもう一度剥がすので、フォームがセクションで `owner_id` を宣言していても、ルートのフィルターとデータ層がいつか別々に編集されても、訪問者は所有者やテナントの所属を偽造できない。権限はフォームの形で、リクエスト時に読み取られる。権限テーブルに「匿名はリードに書ける」という行はない。設定するものが何もないからだ。 + +## 17.7.0 での記録 + +`@objectstack/cli@17.7.0` タグから `examples/app-crm` を取り出し、依存関係を公開済みの 17.7.0 パッケージに固定して、`objectstack dev` で起動した。開発サーバーは空のデータベースに管理者アカウントをシードし、シードされた五件のリードはその管理者のものだ。以下のレスポンスはすべて実物で、長さのために切り詰めただけだ。 + +**1. 扉は自分を説明する——幅はフィールド五つ。** 資格情報なしで: + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +`objectSchema` にはホワイトリストの五フィールドがちょうど載っている。`crm_lead` のオブジェクト定義は十三の業務フィールドを宣言しているが、公開スキーマは五つだけを見せ、`status`、`lead_score`、`assigned_to`、`account`、`notes` などには触れない。訪問者は何を試せばいいかすら知りようがない。 + +**2. まっとうな送信は一行を作る。** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"` は送っていない。オブジェクトの書き込み時デフォルトで、リードオブジェクトはその理由を明記して宣言している:「so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required`」(status を露出しない公開 Web-to-Lead フォームのような最小の作成でも `required` を満たせるように)。`source: "web"` と `lead_score: 0` もデフォルトだ。 + +**3. 悪意ある送信も、同じ一行を作る。** 六つのキーを送り、フォーム上にあるのは二つ: + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +管理者として読み返すと、保存された行は同じだ。ホワイトリストの二フィールドは届き、フォームにない二つの業務フィールドは落とされ、二つのシステムアンカーは落とされたうえでサーバーが刻印した——`organization_id` は `org_other` ではなくこのデプロイ自身の組織で、`owner_id` は `null`。`201` はどのキーが拒否されたかを告げない。ルートは黙って落とす。 + +| 送ったキー | フォーム上に? | 保存値 | 理由 | +|:--|:--|:--|:--| +| `name`、`email` | ある | 送ったまま | ホワイトリスト | +| `status: "converted"` | ない | `"new"` | `sections[].fields` にない。オブジェクトのデフォルトが適用 | +| `lead_score: 100` | ない | `0` | `sections[].fields` にない。オブジェクトのデフォルトが適用 | +| `owner_id: "attacker"` | ない | `null` | サーバー管理の名前。両方の網で拒否 | +| `organization_id: "org_other"` | ない | サーバー自身の値 | サーバー管理の名前。ランタイムが刻印 | + +**4. 扉は逆向きには開かない。** 同じ匿名クライアントが、今度は読む: + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +どちらも `401`——一覧も、訪問者が一秒前に作ったその一行も。`201` のボディが、送信者がそのレコードを目にする唯一の機会だ。「The public path only ever **inserts**; it never lists」(公開パスは挿入しかしない。一覧は決して返さない)。 + +**5. フォームを撤回すると、両方のエンドポイントが閉じる。** `publicLink` は残し、`enabled: false` にして、再ビルドし、再起動する: + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +管理者として読んだリード件数は前後とも 15 のままだった。四つのステータスコードが扉の物語のすべてを語る:`201`、`201`、`401`、`404`。 + +## どちらの話が荷重を支えているのか:guest プロファイルか、宣言か + +ここでドキュメントと同梱サンプルは食い違っていて、私は読んだだけで肩を持ちたくなかった。ガイドは言う:「Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant」(公開フォームビューを宣言せよ。送信ルートはその宣言から狭い権限を導出する。`guest` プロファイルも、広い権限も不要)。CRM サンプルは `GuestPortalProfile` を同梱している——`crm_lead` に `allowCreate: true`、他のビットはすべて false の、`guest_portal` という名の権限セットだ——そしてフォームの上のコメントは「`sharing.allowAnonymous` が公開エンドポイントを開き、`guest_portal` プロファイル(crm_lead への INSERT のみ)が書き込みを認可する」と述べる。ランタイム自身の 17.7.0 のソースは、数百行を隔てて両方の話を抱えている。ルートの docstring はセキュリティが「実行コンテキストに載せた `guest_portal` 権限セットに委譲される」と言い、それがなければミドルウェアは「falls open」(開いたまま落ちる)と警告する。その下のハンドラーのコードは、公開フォームは「work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal`」(デプロイ側で `guest_portal` を設定しなくても、セキュアバイデフォルト(匿名拒否)のもとで動く)と言い、プロファイル名をコンテキストに残すのは「for back-compat with object hooks」(オブジェクトフックとの後方互換のため)だけだとする。 + +そこで `GuestPortalProfile` をスタックの `permissions` から外し、再ビルドした。最初に起きたのは、ビルドの拒否だった: + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +このゲートはそれ自体がひとつの話題で、ここでは安心させてくれる余談だ——権限セットの消失は、そのセットが結局何もしていなかったとしても、レビューすべき変更として扱われる。私はドリフトを受け入れ、コンパイルされたアーティファクトの権限セットが二つではなく一つになったことを確認して、四つのプローブをもう一度走らせた。 + +| プローブ | `guest_portal` あり | `guest_portal` なし | +|:--|:--|:--| +| `name`、`email` を送る `POST …/submit` | `201`、行が作られる | `201`、行が作られる | +| `status`、`owner_id`、`organization_id` を紛れ込ませる `POST …/submit` | `201`。`status: "new"`、`owner_id: null`、サーバーの `organization_id` | 同一 | +| 匿名の `GET /api/v1/data/crm_lead` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| 作った行への匿名 `GET` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +一行ずつ同一だった。17.7.0 では宣言が荷重を支えていて、プロファイルはこのパスでは不活性だ。実務上の意味はこうだ。公開フォームを動かすために guest プロファイルを足してはいけない。それは公開の扉が決して参照しない常設の権限をアクセスマトリクスに加え、次のレビュアー——人であれエージェントであれ——に、ランタイムが共有していない「境界はここだ」という話を伝えてしまう。サンプルのコメントは 17.7.0 については誤りで、私はそのように報告した。直るまでは、コメントではなくガイドを読んでほしい。 + +サンプルとガイドが袂を分かつ場所がもうひとつある。ガイドの第三ステップは「Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation」(オブジェクトに `sharingModel: 'private'` を設定し、送信後はスタッフの範囲に収める)だが、CRM のリードオブジェクトはあえて `public_read_write` で、デモが意図的に組織共有であることをコメントが説明している。これは扉を弱めない——組織全体のデフォルトが決めるのは**サインイン済み**ユーザーが互いの行をどこまで見るかで、匿名呼び出しはそれが参照される前に拒否される——が、自分の受付オブジェクトでは従う価値のある助言だ。 + +## この扉がしないこと + +- **レート制限も、CAPTCHA も、スパム対策もない。** 公開フォームのパスについては何も文書化されておらず、私も何も見つけなかった。この記事のプローブは資格情報なしでリードを十件作り、ランタイムは数えもしなければ遅らせもしなかった。`/api/v1/forms/*` の手前にいつものエッジ防御を置くこと。ランタイムが守るのは何が書かれるかであって、どれだけの頻度で書かれるかではない。 +- **フォームの `required` は公開送信では強制されない。** web-to-lead フォームは `email` を必須にしているが、それなしの `POST` は `201` を返し、`email: null` だった。オブジェクトの `required` は強制される——`name` なしの `POST` は `400 VALIDATION_FAILED` と「Lead Name is required」を返した。あるフィールドが必ず存在しなければならないなら、オブジェクトで必須にするかフックで検査すること。このパスでは、フォームのフラグは描画のヒントだ。 +- **`201` は行をまるごとエコーする。** 権限が許す読み返しは訪問者が作った行で、サーバーが刻印した値とデフォルトも含む——`organization_id` もそのひとつ。私の場合に機微なものはなかった。受付オブジェクトに意味のあるデフォルトを持つ内部フィールドがあるなら、送信者がそれを一度は目にすると想定すること。ターゲットオブジェクトの `beforeInsert` や `afterInsert` フックが刻印するものも同じで、既存レコードから導出した値も含まれる。17.7.0 では、既存レコードを照会するフック——重複排除やラウンドロビンの割り当て——を公開受付オブジェクトの挿入パスから外し、そうしたルーティングは受付の後に、フローやスケジュールジョブで行うこと。 +- **所有者は誰でもない。** 公開送信では `owner_id` は `null` のままで、セキュリティプラグインはそれを「for object hooks / the first-admin bootstrap to assign」(オブジェクトフックまたは最初の管理者ブートストラップが割り当てるために)残している。パイプラインでリードを割り当てるなら、フックかフローでやること。行が誰かに属していることを期待してはいけない。 +- **撤回には再ビルドが要る——忘れれば `os dev` が教えてくれる。** ステップ 5 の最初の試みは何も変えなかった。`objectstack dev --no-watch` が前回の `dist/objectstack.json` を提供し、ログの五行目でそう言っていたからだ:「dist/objectstack.json is OLDER than your sources — this boot serves the STALE build」(dist/objectstack.json はソースより古い——この起動は古いビルドを提供する)。表示された修正方法は効いた。これに触れるのは、ステータスコードしか読まないエージェント駆動のループなら「このスイッチは効かない」と結論していただろうからだ。 +- **メタデータ層をまたぐ撤回には、この記事が歩かなかった縁がある。** 組織が保存したビューのコピーや、パッケージが出荷するフォームは、ガイドの中で独自のルールに従う。ここで試した単一のスイッチが、ほとんどのアプリに必要なものだ。 + +## レビュアーの五つの問い + +フォームを書いたのが人でもエージェントでも、レビューは同じで、しかも短い。権限がフォームそのものだからだ。 + +1. **どのフォームが公開か?** `sharing.enabled`、`sharing.allowAnonymous`、`publicLink` が揃ったフォームビューはすべて匿名エンドポイントだ。コンパイルされたアーティファクトがそれらを列挙している。他に見る場所はない。 +2. **`sections[].fields` に何があるか?** その一覧から十二のサーバー管理名を除いたものが、公開の書き込み面だ。ファイアウォールのルールを読むように読むこと。 +3. **フォームが省いたものをオブジェクトが埋めるか?** フォームが集めない必須フィールドにはオブジェクトレベルのデフォルト——サンプルの `status`——が要る。さもなければ送信は失敗する。そして必ず存在すべきフィールドは、フォームではなくオブジェクトに `required` を持たせること。 +4. **どこかに常設の匿名権限がないか?** `guest` や `everyone` のポジションに結びついた権限セットや、「フォームを動かすために」足した `guest_portal` セットは不要で、扉が使うものを何も広げない。その存在は問いとして扱うこと。 +5. **扉の手前には何があるか?** レート制限とボット対策はデプロイの仕事だ。 + +これは[エージェントの権限はどこで強制されるか](/en/blog/objectos-agent-permission-boundaries/)と同じ「強制の場所」の論法を、アイデンティティをまったく持たない呼び出し手に当てはめたものだ。チェックはルートとデータ層で走るのであって、たまたまフィールドを隠している UI で走るのではない。そして[Lovable の記事](/ja/blog/is-lovable-safe-for-production/)が述べるレビュー問題への答えでもある——説明責任を負う所有者は五行のフィールド一覧を読んで承認できるが、生成されたポリシーではそうはいかない。用語集の[権限モデル](/en/glossary/permission-model/)の項に全体の形がある。この扉は「デフォルト拒否」に対する唯一の意図的な例外で、その形はフォームそのものだ。 + +## エージェントを向ける + +アプリをエージェントが書くなら、渡すべきはこの記事ではなくルールだ。公開フォームは `sharing` ブロックを持つ `formViews` のエントリであり、フィールド一覧が境界であり、デフォルトと必須はオブジェクトが持ち、権限セットは関与しない。[ObjectStack 向けにコーディングエージェントを設定するガイド](/en/blog/how-to-point-your-agent-at-objectstack/)は、そのルールがエージェントにどう届くかを扱っている——ルールファイル、起動中のアプリが公開する MCP エンドポイント、そして spec だ。`npm create objectstack@latest` がインストールするのは 17.7.0、この記録を走らせたバージョンである。 + +同じループをホスト型で、ブラウザだけで、何もインストールせずに使いたい?それが ObjectOS — ObjectStack の上に構築された商用ランタイム環境です。 diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.ko.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.ko.mdx new file mode 100644 index 0000000..e6dab8b --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.ko.mdx @@ -0,0 +1,211 @@ +--- +title: "익명 방문자가 거버넌스된 업무 앱에 폼을 제출하게 하려면? 공개된 문 하나, 너비는 필드 다섯 개" +description: "v17부터 익명 데이터 접근은 항상 거부된다. 유일하게 열린 문은 공개 폼 뷰다. 런타임이 폼 자체에서 권한을 도출한다 — 한 행을 만들고, 그 한 행을 읽어 돌려주고, 필드는 다섯 개, 그 외에는 아무것도 없다. 17.7.0에서 실측." +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover-en.svg +tags: + - 공개 폼 + - Web-to-Lead + - 익명 접근 +--- + +**요약.** v17부터 ObjectStack 앱에서 인증되지 않은 객체 데이터 요청은 `401 UNAUTHENTICATED`로 거부된다 — 모든 객체, 모든 엔드포인트에서, 예외 스위치 없이. 거버넌스된 앱이 외부에 열어 두는 유일한 문은 **공개 폼 뷰**다. 폼을 선언하고 `sharing` 블록에 `allowAnonymous: true`를 표시하면, 런타임은 그 선언에서 방문자의 권한 전체를 도출한다 — 그 한 객체에 레코드 하나를 만들고, 방금 만든 행을 읽어 돌려주고, 그 외에는 아무것도 하지 않는다. 폼의 필드 목록이 곧 보안 경계다. CRM 예제의 web-to-lead 폼은 필드 다섯 개를 나열하므로 다섯 개가 들어올 수 있다. 나머지 업무 필드 여덟 개와 모든 시스템 컬럼은 방문자가 무엇을 보내든 들어오지 못한다. 나는 이것을 **폼 모양의 권한**(form-shaped grant)이라 부르겠다. 권한이 요청 시점에 폼에서 계산되므로, 폼보다 넓게 설정될 수가 없다. 아래의 모든 내용은 공개된 `@objectstack/cli` 17.7.0 — 오늘 `npm create objectstack@latest`가 설치하는 버전 — 에서 실측했다. 문서와 동봉 예제가 유일하게 엇갈리는 질문, `guest_portal` 프로필이 필요한가도 포함해서. 답은 “필요 없다”이다. + +## 문제, 문서 자신의 말로 + +ObjectStack의 공개 데이터 수집 가이드는 사용자가 말할 법한 시나리오로 시작한다: + +> 익명 방문자가 “문의하기”, “데모 신청”, 접수 폼 같은 것을 제출해 레코드가 생성되게 하고 싶다. 하지만 기반 데이터에 대한 접근은 **절대** 주어서는 안 되고, 그들은 폼에 있는 필드만 설정할 수 있어야 한다. + +모든 업무 앱에는 이런 폼이 하나씩 있다. 웹사이트의 리드 폼, 지원 접수, 데모 신청, 보증 청구. 흔한 답들에는 저마다 대가가 따른다. “guest” 역할에 테이블 쓰기 권한을 주는 것은 아무도 다시 읽지 않는 상설 권한이고, 표류한다. 손으로 짠 엔드포인트는 나머지 모든 것을 다스리는 권한 모델을 우회한다. 외부 폼 도구에 webhook을 붙이면 이제 스키마를 가진 시스템이 둘이 되고, 그중 하나는 검토자가 보지 않는 쪽이다. + +v17부터는 첫 번째 답이 아예 존재하지 않는다. 권한 문서가 지나가듯 그 이유를 기록한다: “since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired” (v17부터 익명의 객체 데이터 접근은 어차피 항상 거부된다 — `requireAuth: false` 옵트아웃은 은퇴했다). [셀프 호스팅 안내 글](/en/blog/how-to-self-host-objectstack/)은 이를 검증 단계로 만든다 — 자격 증명 없이 객체 엔드포인트에 `curl`을 날려 기계가 읽을 수 있는 `code`가 붙은 `401`을 요구하고, 거기서 `200`이 나오면 사고로 취급한다. 그러니 질문을 정직하게 바꾸면 이렇다. 데이터 플레인이 구조적으로 익명 호출자에게 닫혀 있다면, 공개 폼은 도대체 어떻게 통과하는가? + +## 공개 폼은 선언이지, 권한이 아니다 + +다음은 CRM 예제의 web-to-lead 폼 전문으로, 17.7.0 태그에 출하된 그대로다: + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +권한을 언급하는 말은 한마디도 없다. 문을 여는 것은 `sharing` 블록이다. `enabled`, `allowAnonymous`, `publicLink`가 모두 설정되면 런타임은 익명 엔드포인트 둘을 등록한다 — 해석된 폼과 화이트리스트된 스키마를 돌려주는 `GET /api/v1/forms/contact-us`, 그리고 `crm_lead` 하나를 삽입하는 `POST /api/v1/forms/contact-us/submit`. 방문자가 쓸 수 있는 것에 대한 문서의 한 줄 규칙은 “Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side” (제출 시 `sections[].fields`에 나열된 필드만 받고, 나머지는 서버 측에서 벗겨낸다)이다. 그리고 왜 이렇게 만들었는지에 대한 이유야말로 이 글이 검증하려고 존재하는 문장이다: “Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form” (권한은 선언에서 도출되는 것이지 따로 설정되는 것이 아니다 — 그래서 권한이 폼보다 넓게 표류할 수 없다). + +![두 개의 그물: 라우트는 선언된 필드 다섯 개만 남기고 서버가 관리하는 이름을 버린다. 데이터 계층은 crm_lead에 대한 삽입과 읽기 반환만 허용하고 같은 이름을 다시 벗겨낸다. 읽기는 401, 철회된 폼은 404로 응답한다.](./two-nets-en.svg) + +기계적으로는 그물이 둘이고, 각각 이름을 붙일 가치가 있다. 검토자가 하나씩 확인할 수 있기 때문이다. + +**첫 번째 그물은 라우트다.** 제출 핸들러는 일치한 폼의 `sections[].fields`에서 허용 목록을 만들고, 요청 본문에서 그 키들만 복사한다. 그 전에 spec 패키지가 내보내는 집합 `PUBLIC_FORM_SERVER_MANAGED_FIELDS`의 모든 키 — `id`, `owner_id`, `owning_business_unit_id`, `organization_id`, `tenant_id`, `created_at`, `created_by`, `updated_at`, `updated_by`, `is_deleted`, `deleted_at`, `__search`, 열두 개의 이름 — 를 이름으로 버린다. 문서에 따르면 이들은 “**never** accepted from a public submission — not even when a section declares them” (공개 제출에서는 절대 받지 않는다 — 섹션이 선언하더라도). 필드가 하나도 없는 폼은 들어오는 것을 무엇이든 받는 대신 `400`으로 거부된다. + +**두 번째 그물은 데이터 계층이다.** 라우트는 쓰기에 요청 범위의 `publicFormGrant: { object: 'crm_lead' }`를 붙이고, 보안 플러그인은 정확히 그것만 존중한다 — `insert`, 그리고 읽기 반환을 위한 `find`, `findOne`, `count`, 그것도 그 객체에 한해서. 삽입을 허용하기 전에 같은 열두 개의 이름을 한 번 더 벗겨내므로, 폼이 섹션에 `owner_id`를 선언했더라도, 라우트 필터와 데이터 계층이 언젠가 따로 편집되더라도, 방문자는 소유권이나 테넌트 소속을 위조할 수 없다. 권한은 폼의 모양이고, 요청 시점에 읽힌다. 권한 테이블에 “익명은 리드에 쓸 수 있다”는 행은 없다. 설정할 것이 아무것도 없기 때문이다. + +## 17.7.0에서의 기록 + +`@objectstack/cli@17.7.0` 태그에서 `examples/app-crm`을 가져와 의존성을 공개된 17.7.0 패키지에 고정하고 `objectstack dev`로 시작했다. 개발 서버는 빈 데이터베이스에 관리자 계정을 시드하며, 시드된 리드 다섯 개는 그 관리자 소유다. 아래의 모든 응답은 실제이며 길이 때문에만 잘라냈다. + +**1. 문은 스스로를 설명한다 — 너비는 필드 다섯 개.** 자격 증명 없이: + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +`objectSchema`에는 화이트리스트된 필드 다섯 개가 정확히 담겨 있다. `crm_lead`의 객체 정의는 업무 필드 열세 개를 선언하지만, 공개 스키마는 다섯 개만 보여 주고 `status`, `lead_score`, `assigned_to`, `account`, `notes`와 나머지에 대해서는 아무 말도 하지 않는다. 방문자는 무엇을 시도해야 할지조차 알 수 없다. + +**2. 정상적인 제출은 한 행을 만든다.** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"`는 보내지 않았다. 객체의 쓰기 경로 기본값이며, 리드 객체는 그 이유를 명시해 선언한다: “so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required`” (status를 노출하지 않는 공개 Web-to-Lead 폼 같은 최소한의 생성도 `required`를 만족하도록). `source: "web"`과 `lead_score: 0`도 기본값이다. + +**3. 악의적인 제출도 같은 행을 만든다.** 키 여섯 개를 보냈고, 폼에 있는 것은 둘: + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +관리자로 다시 읽어 보면 저장된 행은 똑같다. 화이트리스트된 두 필드는 도착했고, 폼에 없는 업무 필드 둘은 버려졌으며, 시스템 앵커 둘은 버려진 뒤 서버가 도장을 찍었다 — `organization_id`는 `org_other`가 아니라 이 배포 자신의 조직이고, `owner_id`는 `null`이다. `201`은 어떤 키가 거부됐는지 말하지 않는다. 라우트는 말없이 버린다. + +| 보낸 키 | 폼에 있나? | 저장된 값 | 이유 | +|:--|:--|:--|:--| +| `name`, `email` | 예 | 보낸 그대로 | 화이트리스트 | +| `status: "converted"` | 아니오 | `"new"` | `sections[].fields`에 없음. 객체 기본값 적용 | +| `lead_score: 100` | 아니오 | `0` | `sections[].fields`에 없음. 객체 기본값 적용 | +| `owner_id: "attacker"` | 아니오 | `null` | 서버가 관리하는 이름. 두 그물 모두에서 거부 | +| `organization_id: "org_other"` | 아니오 | 서버 자신의 값 | 서버가 관리하는 이름. 런타임이 도장 | + +**4. 문은 반대 방향으로 열리지 않는다.** 같은 익명 클라이언트가 이번에는 읽는다: + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +둘 다 `401`이다 — 목록도, 방문자가 1초 전에 만든 바로 그 행도. `201` 본문이 제출자가 그 레코드를 보는 유일한 순간이다. “The public path only ever **inserts**; it never lists” (공개 경로는 삽입만 한다. 결코 목록을 돌려주지 않는다). + +**5. 폼을 철회하면 두 엔드포인트가 모두 닫힌다.** `publicLink`는 유지하고 `enabled: false`로 두고, 다시 빌드하고, 재시작한다: + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +관리자로 읽은 리드 수는 전후 모두 15였다. 상태 코드 네 개가 이 문의 이야기 전부를 말해 준다: `201`, `201`, `401`, `404`. + +## 어느 쪽 이야기가 하중을 받는가: guest 프로필인가, 선언인가 + +여기서 문서와 동봉 예제가 엇갈리는데, 나는 읽기만으로 편을 들고 싶지 않았다. 가이드는 말한다: “Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant” (공개 폼 뷰를 선언하라. 제출 라우트가 그 선언에서 좁은 권한을 도출한다. `guest` 프로필도, 넓은 권한도 없다). CRM 예제는 `GuestPortalProfile`을 동봉한다 — `crm_lead`에 `allowCreate: true`이고 다른 모든 비트는 false인, `guest_portal`이라는 이름의 권한 집합이다 — 그리고 폼 위의 주석은 “`sharing.allowAnonymous`가 공개 엔드포인트를 열고, `guest_portal` 프로필(crm_lead에 INSERT만)이 쓰기를 인가한다”고 말한다. 런타임 자신의 17.7.0 소스는 수백 줄을 사이에 두고 두 이야기를 모두 품고 있다. 라우트의 docstring은 보안이 “실행 컨텍스트에 실린 `guest_portal` 권한 집합에 위임된다”고 하고, 그것이 없으면 미들웨어가 “falls open” (열린 채로 떨어진다)한다고 경고한다. 그 아래의 핸들러 코드는 공개 폼이 “work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal`” (배포에 `guest_portal`을 설정하지 않아도 기본 보안(익명 거부) 아래에서 동작한다)고 하고, 프로필 이름을 컨텍스트에 남기는 것은 “for back-compat with object hooks” (객체 훅과의 하위 호환을 위해)일 뿐이라고 한다. + +그래서 `GuestPortalProfile`을 스택의 `permissions`에서 제거하고 다시 빌드했다. 처음 일어난 일은 빌드의 거부였다: + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +그 게이트는 그 자체로 하나의 주제이고, 여기서는 안심시켜 주는 여담이다 — 권한 집합이 사라지는 것은, 그 집합이 결국 아무것도 하지 않는 것으로 드러나더라도, 검토할 변경으로 취급된다. 나는 드리프트를 받아들이고, 컴파일된 아티팩트에 권한 집합이 둘이 아닌 하나만 있음을 확인한 뒤, 네 가지 프로브를 다시 돌렸다. + +| 프로브 | `guest_portal` 있음 | `guest_portal` 없음 | +|:--|:--|:--| +| `name`, `email`을 보내는 `POST …/submit` | `201`, 행 생성 | `201`, 행 생성 | +| `status`, `owner_id`, `organization_id`를 끼워 넣는 `POST …/submit` | `201`; `status: "new"`, `owner_id: null`, 서버의 `organization_id` | 동일 | +| 익명 `GET /api/v1/data/crm_lead` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| 만든 행에 대한 익명 `GET` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +한 줄 한 줄 동일했다. 17.7.0에서는 선언이 하중을 받고, 프로필은 이 경로에서 비활성이다. 실무적 의미는 이렇다. 공개 폼을 동작시키려고 guest 프로필을 추가하지 말라. 그것은 공개된 문이 결코 참조하지 않는 상설 권한을 접근 행렬에 더하고, 다음 검토자 — 사람이든 에이전트든 — 에게 런타임이 공유하지 않는 “경계가 어디인가”에 대한 이야기를 들려준다. 예제의 주석은 17.7.0에 관해서는 틀렸고, 나는 그렇게 보고했다. 그것이 바뀔 때까지는 주석이 아니라 가이드를 읽으라. + +예제와 가이드가 갈라지는 곳이 하나 더 있다. 가이드의 세 번째 단계는 “Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation” (객체에 `sharingModel: 'private'`을 설정해 제출이 생성 후 직원 범위로 묶이게 하라)인데, CRM의 리드 객체는 의도적으로 `public_read_write`이고, 데모가 일부러 조직 공유라는 설명이 주석에 있다. 이것이 문을 약하게 하지는 않는다 — 조직 전체 기본값이 다스리는 것은 **로그인한** 사용자들이 서로의 행을 얼마나 보는가이고, 익명 호출자는 그것이 참조되기 전에 거부된다 — 하지만 자신의 접수 객체에서는 따를 만한 조언이다. + +## 이 문이 하지 않는 것 + +- **속도 제한도, 캡차도, 스팸 방지도 없다.** 공개 폼 경로에 대해 문서화된 것은 없고, 나도 아무것도 찾지 못했다. 이 글의 프로브는 자격 증명 없이 리드 열 개를 만들었고, 런타임은 세지도 늦추지도 않았다. `/api/v1/forms/*` 앞에 평소의 엣지 보호를 두라. 런타임이 지키는 것은 무엇이 쓰이는가이지, 얼마나 자주 쓰이는가가 아니다. +- **폼의 `required`는 공개 제출에서 강제되지 않는다.** web-to-lead 폼은 `email`을 필수로 표시하지만, 그것 없는 `POST`는 `201`과 `email: null`로 응답했다. 객체의 `required`는 강제된다 — `name` 없는 `POST`는 `400 VALIDATION_FAILED`와 “Lead Name is required”로 응답했다. 어떤 필드가 반드시 있어야 한다면 객체에서 필수로 만들거나 훅에서 검사하라. 이 경로에서 폼의 플래그는 렌더링 힌트다. +- **`201`은 행 전체를 되돌려 준다.** 권한이 허용하는 읽기 반환은 방문자가 만든 행이며, 서버가 도장을 찍은 값과 기본값 — `organization_id`를 포함해 — 도 들어 있다. 내 경우에 민감한 것은 없었다. 접수 객체에 의미 있는 기본값을 가진 내부 필드가 있다면, 제출자가 그것을 한 번은 보게 된다고 가정하라. 대상 객체의 `beforeInsert`나 `afterInsert` 훅이 찍는 값도 마찬가지이며, 기존 레코드에서 도출한 값도 포함된다. 17.7.0에서는 기존 레코드를 조회하는 훅 — 중복 제거, 라운드 로빈 배정 — 을 공개 접수 객체의 삽입 경로 밖에 두고, 그런 라우팅은 접수 뒤에 플로우나 예약 작업에서 하라. +- **소유자는 아무도 아니다.** 공개 제출에서 `owner_id`는 `null`로 남고, 보안 플러그인은 그것을 “for object hooks / the first-admin bootstrap to assign” (객체 훅 또는 첫 관리자 부트스트랩이 할당하도록) 남겨 둔다. 파이프라인이 리드를 배정한다면 훅이나 플로우에서 하라 — 행이 누군가에게 속해 있을 것이라 기대하지 말라. +- **철회에는 재빌드가 필요하다 — 잊으면 `os dev`가 알려 준다.** 5단계의 첫 시도는 아무것도 바꾸지 못했다. `objectstack dev --no-watch`가 이전의 `dist/objectstack.json`을 제공했고, 로그 다섯째 줄에서 그렇게 말했기 때문이다: “dist/objectstack.json is OLDER than your sources — this boot serves the STALE build” (dist/objectstack.json이 소스보다 오래됐다 — 이번 부팅은 오래된 빌드를 제공한다). 출력된 해결책은 효과가 있었다. 이를 언급하는 이유는, 상태 코드만 읽는 에이전트 구동 루프라면 이 스위치가 동작하지 않는다고 결론 내렸을 것이기 때문이다. +- **메타데이터 계층을 가로지르는 철회에는 이 글이 걷지 않은 가장자리가 있다.** 조직이 저장한 뷰 사본과 패키지가 출하하는 폼은 가이드 안에서 각자의 규칙을 따른다. 여기서 시험한 단일 스위치가 대부분의 앱에 필요한 것이다. + +## 검토자의 다섯 가지 질문 + +폼을 사람이 썼든 에이전트가 썼든 검토는 같고, 권한이 폼 자체이기 때문에 짧다. + +1. **어떤 폼이 공개인가?** `sharing.enabled`, `sharing.allowAnonymous`, `publicLink`가 모두 설정된 모든 폼 뷰가 익명 엔드포인트다. 컴파일된 아티팩트가 그것들을 나열한다. 다른 곳을 볼 필요가 없다. +2. **`sections[].fields`에 무엇이 있는가?** 그 목록에서 서버가 관리하는 열두 개의 이름을 뺀 것이 공개 쓰기 표면이다. 방화벽 규칙을 읽듯 읽으라. +3. **폼이 빠뜨린 것을 객체가 채우는가?** 폼이 수집하지 않는 모든 필수 필드에는 객체 수준의 기본값 — 예제의 `status` — 이 필요하다. 아니면 제출이 실패한다. 그리고 반드시 있어야 하는 모든 필드는 폼이 아니라 객체에 `required`가 있어야 한다. +4. **어딘가에 상설 익명 권한이 있는가?** `guest`나 `everyone` 포지션에 바인딩된 권한 집합이나 “폼을 동작시키려고” 추가한 `guest_portal` 집합은 필요하지 않으며, 문이 사용하는 것을 전혀 넓히지 않는다. 그 존재를 질문으로 취급하라. +5. **문 앞에는 무엇이 있는가?** 속도 제한과 봇 보호는 배포의 일이다. + +이것은 [에이전트 권한이 어디서 강제되는가](/en/blog/objectos-agent-permission-boundaries/)와 같은 “강제의 위치” 논증을, 정체성이 전혀 없는 호출자에게 적용한 것이다. 검사는 라우트와 데이터 계층에서 실행되지, 우연히 필드를 숨기고 있는 UI에서 실행되지 않는다. 그리고 [Lovable 글](/ko/blog/is-lovable-safe-for-production/)이 묘사하는 검토 문제에 대한 답이기도 하다 — 책임지는 소유자는 다섯 줄짜리 필드 목록을 읽고 승인할 수 있지만, 생성된 정책에 대해서는 그럴 수 없다. 용어집의 [권한 모델](/en/glossary/permission-model/) 항목에 전체적인 모양이 있다. 이 문은 “기본 거부”에 대한 유일하게 의도된 예외이며, 그 모양은 폼이다. + +## 에이전트를 가리키게 하라 + +앱을 에이전트가 쓴다면, 건네야 할 것은 이 글이 아니라 규칙이다. 공개 폼은 `sharing` 블록을 가진 `formViews` 항목이고, 필드 목록이 경계이며, 기본값과 필수 여부는 객체가 소유하고, 권한 집합은 관여하지 않는다. [ObjectStack용 코딩 에이전트 설정 가이드](/en/blog/how-to-point-your-agent-at-objectstack/)는 그 규칙이 에이전트에 어떻게 도달하는지 다룬다 — 규칙 파일, 실행 중인 앱이 노출하는 MCP 엔드포인트, 그리고 spec. `npm create objectstack@latest`는 이 기록이 실행된 버전인 17.7.0을 설치한다. + +같은 루프를 호스팅으로, 브라우저에서, 아무것도 설치하지 않고 쓰고 싶다면? 그것이 ObjectOS, ObjectStack 위에 구축된 상용 런타임 환경입니다. diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.mdx new file mode 100644 index 0000000..9702951 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.mdx @@ -0,0 +1,211 @@ +--- +title: "How Do You Let Anonymous Visitors Submit a Form Into a Governed Business App? One Public Door, Five Fields Wide" +description: "Since v17 anonymous data access is always denied. The one open door is a public form view: the runtime derives a grant from the form itself — create one row, read back that row, five fields, nothing else. Measured on 17.7.0." +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover-en.svg +tags: + - Public forms + - Web-to-lead + - Anonymous access +--- + +**The short version.** Since v17, an unauthenticated request for object data in an ObjectStack app is refused with `401 UNAUTHENTICATED` — every object, every endpoint, no opt-out. The one door a governed app leaves open to the public is a **public form view**: declare a form, mark its `sharing` block `allowAnonymous: true`, and the runtime derives the visitor's entire authorization from that declaration — create one record on that one object, read back the row it just made, nothing else. The form's field list is the security boundary. The CRM example's web-to-lead form lists five fields, so five fields can arrive; the other eight business fields and every system column cannot, whatever the visitor sends. I will call this a **form-shaped grant**: authorization computed from the form at request time, which is why it cannot be configured wider than the form. Everything below was measured on the published `@objectstack/cli` 17.7.0, the version `npm create objectstack@latest` installs today — including the one question the documentation and the shipped example disagree on, whether a `guest_portal` profile is needed. It is not. + +## The problem, in the doc's own words + +The ObjectStack guide on public data collection opens with the scenario as a user would state it: + +> I want anonymous visitors to submit a form — a "Contact us" / "Request a demo" / intake form — that creates a record. But I must **not** give them access to the base, and they must only be able to set the fields on the form. + +Every business app has one of these: a lead form on the website, a support intake, a demo request, a warranty claim. The usual answers each cost something. A "guest" role with write access to the table is a standing grant that nobody re-reads, and it drifts. A hand-written endpoint bypasses the permission model that governs everything else. A third-party form tool with a webhook means two systems now hold the schema and one of them is not the one your reviewers look at. + +Since v17 the first answer is not even available. The permissions documentation records why in passing: "since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired". The [self-hosting walkthrough](/en/blog/how-to-self-host-objectstack/) turns that into a verification step — `curl` an object endpoint with no credentials and insist on the `401` with a machine-readable `code` — and treats a `200` there as an incident. So the honest form of the question is: if the data plane is closed to anonymous callers by construction, how does a public form get through at all? + +## A public form is a declaration, not a permission + +This is the whole of the CRM example's web-to-lead form, as it ships at the 17.7.0 tag: + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +Nothing in it mentions a permission. The `sharing` block is what opens the door: with `enabled`, `allowAnonymous` and a `publicLink` all set, the runtime registers two anonymous endpoints — `GET /api/v1/forms/contact-us`, which returns the resolved form and the whitelisted schema, and `POST /api/v1/forms/contact-us/submit`, which inserts one `crm_lead`. The doc's one-line rule for what the visitor may write is "Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side." And the reason it gives for building it this way is the sentence this post exists to test: "Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form." + +![Two nets: the route keeps only the five declared fields and drops server-managed names; the data layer admits insert and read-back on crm_lead only and strips the same names again. Reads answer 401; a withdrawn form answers 404.](./two-nets-en.svg) + +Mechanically there are two nets, and they are worth naming because a reviewer can check each one. + +**The first net is the route.** The submit handler builds its allow-list from `sections[].fields` of the matched form and copies only those keys out of the request body. Before it does, it drops by name every key in `PUBLIC_FORM_SERVER_MANAGED_FIELDS`, a set the spec package exports: `id`, `owner_id`, `owning_business_unit_id`, `organization_id`, `tenant_id`, `created_at`, `created_by`, `updated_at`, `updated_by`, `is_deleted`, `deleted_at` and `__search` — twelve names that are "**never** accepted from a public submission — not even when a section declares them". A form with no fields at all is refused with `400` rather than accepting whatever arrives. + +**The second net is the data layer.** The route attaches a request-scoped `publicFormGrant: { object: 'crm_lead' }` to the write, and the security plugin honours exactly that: `insert`, plus `find`, `findOne` and `count` for the read-back, on that object only. Before admitting the insert it strips the same twelve names a second time, so that even if a form declared `owner_id` in a section, or the route filter and the data layer were ever edited apart, a visitor could not forge ownership or tenant placement. The grant is the form's shape, read at request time. There is no row in a permissions table that says "anonymous may write to leads", because there is nothing to configure. + +## The transcript, on 17.7.0 + +I took `examples/app-crm` from the `@objectstack/cli@17.7.0` tag, pinned its dependencies to the published 17.7.0 packages, and started it with `objectstack dev`. The dev server seeds an admin account on an empty database; the five seeded leads belong to it. Every response below is real and trimmed only for length. + +**1. The door describes itself — five fields wide.** With no credentials: + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +The `objectSchema` carries exactly the five whitelisted fields. `crm_lead` declares thirteen business fields in its object definition; the public schema shows five and says nothing about `status`, `lead_score`, `assigned_to`, `account`, `notes` or the rest. The visitor cannot even learn what to try. + +**2. A clean submission creates one row.** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"` was not sent; it is the object's write-path default, which the lead object declares precisely "so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required`". `source: "web"` and `lead_score: 0` are defaults too. + +**3. A hostile submission creates the same row.** Six keys sent; two are on the form: + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +Read back as the admin, the stored row is the same: the two whitelisted fields arrived, the two business fields that are not on the form were dropped, and the two system anchors were dropped and then stamped by the server — `organization_id` is the deployment's own organization, not `org_other`, and `owner_id` is `null`. The `201` does not say which keys were refused; the route drops them without comment. + +| Key sent | On the form? | Stored as | Why | +|:--|:--|:--|:--| +| `name`, `email` | yes | as sent | whitelisted | +| `status: "converted"` | no | `"new"` | not in `sections[].fields`; object default applies | +| `lead_score: 100` | no | `0` | not in `sections[].fields`; object default applies | +| `owner_id: "attacker"` | no | `null` | server-managed name, denied in both nets | +| `organization_id: "org_other"` | no | the server's own | server-managed name; stamped by the runtime | + +**4. The door does not open the other way.** The same anonymous client, now reading: + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +Both answer `401` — the list, and the single row the visitor created a second earlier. The `201` body is the only time the submitter ever sees that record. "The public path only ever **inserts**; it never lists." + +**5. Withdrawing the form closes both endpoints.** Keep the `publicLink`, set `enabled: false`, rebuild, restart: + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +The lead count read as the admin stayed at 15 before and after. Four status codes tell the whole story of the door: `201`, `201`, `401`, `404`. + +## Which story is load-bearing: the guest profile or the declaration? + +Here the documentation and the shipped example disagree, and I did not want to pick a side by reading. The guide says: "Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant". The CRM example ships a `GuestPortalProfile` — a permission set named `guest_portal` with `allowCreate: true` on `crm_lead` and every other bit false — and the comment above the form says that "`sharing.allowAnonymous` opens the public endpoint; the `guest_portal` profile (INSERT-only on crm_lead) authorizes the write." The runtime's own source at 17.7.0 carries both stories a few hundred lines apart: the route's docstring says security "is delegated to the `guest_portal` permission set carried on the execution context" and warns that without one the middleware "falls open"; the handler's code, below it, says public forms "work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal`" and keeps the profile name on the context only "for back-compat with object hooks". + +So I removed `GuestPortalProfile` from the stack's `permissions` and rebuilt. The first thing that happened was that the build refused: + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +That gate is its own topic; here it is a reassuring aside — a permission set disappearing is treated as a reviewable change even when the set turns out to do nothing. I accepted the drift, confirmed the compiled artifact listed one permission set instead of two, and ran the four probes again. + +| Probe | With `guest_portal` | Without `guest_portal` | +|:--|:--|:--| +| `POST …/submit` with `name`, `email` | `201`, row created | `201`, row created | +| `POST …/submit` smuggling `status`, `owner_id`, `organization_id` | `201`; `status: "new"`, `owner_id: null`, server's `organization_id` | identical | +| anonymous `GET /api/v1/data/crm_lead` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| anonymous `GET` of the created row | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +Identical, line for line. On 17.7.0 the declaration is load-bearing and the profile is inert for this path. What that means in practice: do not add a guest profile to make a public form work. It adds a standing grant to your access matrix that the public door never consults, and it tells the next reviewer — human or agent — a story about where the boundary is that the runtime does not share. The example's comment is wrong for 17.7.0, and I have reported it as such; until it changes, read the guide, not the comment. + +One more place the example and the guide part ways: the guide's step three is "Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation", and the CRM's lead object is deliberately `public_read_write`, with a comment explaining that the demo is org-shared on purpose. That does not weaken the door — the organization-wide default governs what *signed-in* users see of each other's rows, and the anonymous caller is refused before any of that is consulted — but it is advice worth following on your own intake object. + +## What this door does not do + +- **No rate limiting, no captcha, no spam control.** None is documented for the public form path, and I found none: the probes in this article created ten leads with no credentials, and nothing in the runtime counted or slowed them. Put the usual edge protection in front of `/api/v1/forms/*`; the runtime guards what gets written, not how often. +- **The form's `required` is not enforced on the public submit.** The web-to-lead form marks `email` required; a `POST` without it answered `201` with `email: null`. The object's `required` is enforced — a `POST` without `name` answered `400 VALIDATION_FAILED` with `"Lead Name is required"`. If a field must be present, require it on the object or check it in a hook; the form flag is a rendering hint on this path. +- **The `201` echoes the whole row.** The read-back the grant allows is the row the visitor created, including server-stamped values and defaults — `organization_id` among them. Nothing sensitive was in mine. If your intake object carries internal fields with meaningful defaults, assume the submitter will see them once. The same goes for whatever a `beforeInsert` or `afterInsert` hook on the target object stamps, including values it derives from existing records. On 17.7.0, keep hooks that look up existing records — de-duplication, round-robin assignment — out of the insert path of a public-intake object, and do that routing after intake, in a flow or a scheduled job. +- **The owner is nobody.** `owner_id` stays `null` on a public submission; the security plugin leaves it "for object hooks / the first-admin bootstrap to assign". If your pipeline assigns leads, do it in a hook or a flow — do not expect the row to belong to a person. +- **A withdrawal takes a rebuild — and `os dev` will tell you if you forgot.** My first attempt at step five changed nothing, because `objectstack dev --no-watch` served the previous `dist/objectstack.json` and said so in its fifth log line: "dist/objectstack.json is OLDER than your sources — this boot serves the STALE build." The fix it printed worked. I mention it because an agent-driven loop that only reads status codes would have concluded the switch does not work. +- **Withdrawal across metadata layers has edges this post did not walk.** An organization's saved copy of a view, and forms shipped by packages, follow their own rules in the guide. The single switch tested here is the one most apps need. + +## A reviewer's five questions + +Whether the form was written by a person or by an agent, the review is the same, and it is short because the grant is the form. + +1. **Which forms are public?** Every form view with `sharing.enabled`, `sharing.allowAnonymous` and a `publicLink` all set is an anonymous endpoint. The compiled artifact lists them; there is nowhere else to look. +2. **What is in `sections[].fields`?** That list, minus the twelve server-managed names, is the public write surface. Read it as you would read a firewall rule. +3. **Does the object fill in what the form leaves out?** Every required field the form does not collect needs an object-level default — `status` in the example — or the submission fails. And every field that must be present needs its `required` on the object, not on the form. +4. **Is there a standing anonymous grant anywhere?** A permission set bound to a `guest` or `everyone` position, or a `guest_portal` set added "to make the form work", is not needed and widens nothing the door uses. Treat its presence as a question. +5. **What is in front of the door?** Rate limits and bot protection are the deployment's job. + +This is the same place-of-enforcement argument as [where agent permissions are enforced](/en/blog/objectos-agent-permission-boundaries/), applied to a caller with no identity at all: the check runs in the route and the data layer, not in the UI that happens to hide the fields. And it is the answer to the review problem [the Lovable post](/en/blog/is-lovable-safe-for-production/) describes — the accountable owner can read the five-line field list and sign off on it, which is not true of a generated policy. The glossary entry on the [permission model](/en/glossary/permission-model/) has the general shape; this door is the one deliberate exception to "deny by default", and it is shaped like the form. + +## Point your agent at it + +If an agent writes your app, the thing to hand it is not this article but the rule: a public form is a `formViews` entry with a `sharing` block, the field list is the boundary, the object owns defaults and requiredness, and no permission set is involved. The [guide to configuring a coding agent for ObjectStack](/en/blog/how-to-point-your-agent-at-objectstack/) covers how that rule reaches the agent — the rule file, the MCP endpoint a running app exposes, and the spec. `npm create objectstack@latest` installs 17.7.0, the version this transcript ran on. + +Want the same loop hosted, in the browser, nothing to install? That's ObjectOS, the commercial runtime environment built on ObjectStack. diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.zh-Hans.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.zh-Hans.mdx new file mode 100644 index 0000000..ebf6019 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.zh-Hans.mdx @@ -0,0 +1,211 @@ +--- +title: 怎样让匿名访客把表单提交进一个受治理的业务应用?一道公开的门,五个字段宽 +description: v17 起匿名访问对象数据一律拒绝。唯一开着的门是公开表单视图:运行时从表单自身推导授权——创建一行、读回这一行、五个字段,仅此而已。在 17.7.0 上实测。 +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover.svg +tags: + - 公开表单 + - Web-to-Lead + - 匿名访问 +--- + +**先给结论**:从 v17 起,ObjectStack 应用里任何未经认证的对象数据请求都会被 `401 UNAUTHENTICATED` 拒绝——每个对象、每个端点,没有例外开关。一个受治理的应用向公众留下的唯一一道门,是**公开表单视图**:声明一个表单,把它的 `sharing` 块标为 `allowAnonymous: true`,运行时就从这份声明推导出访客的全部授权——在那一个对象上创建一条记录,读回它刚创建的那一行,别的什么都不能做。表单的字段列表就是安全边界。CRM 示例的 web-to-lead 表单列了五个字段,于是五个字段能进来;其余八个业务字段和每一个系统列都进不来,不管访客发了什么。我把它称为**表单形状的授权**(form-shaped grant):授权在请求时从表单算出来,所以它不可能被配置得比表单更宽。下文的每一项都在已发布的 `@objectstack/cli` 17.7.0 上实测,也就是今天 `npm create objectstack@latest` 装上的版本——包括文档和自带示例唯一一处说法不一致的问题:到底需不需要一个 `guest_portal` 配置文件。答案是不需要。 + +## 问题,用文档自己的话说 + +ObjectStack 关于公开数据收集的指南,开头就用用户的口吻写下了场景: + +> 我想让匿名访客提交一个表单——「联系我们」「申请演示」或者录入表单——它会创建一条记录。但我绝对**不能**把底层数据开放给他们,而且他们只能设置表单上的那些字段。 + +每个业务应用都有这么一张表:网站上的线索表单、支持工单录入、演示申请、保修申请。常见的几种做法各有代价。给一个「guest」角色加上对这张表的写权限,是一份没人会再读的常设授权,它会漂移。手写一个端点,绕开了治理其他一切的权限模型。用第三方表单工具加 webhook,意味着现在有两套系统各持一份 schema,而审阅者看的不是其中那一份。 + +从 v17 起,第一种做法根本不存在了。权限文档顺带记录了原因:「since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired」(自 v17 起匿名访问对象数据本来就一律拒绝——`requireAuth: false` 这个退出开关已经退役)。[自托管实操](/en/blog/how-to-self-host-objectstack/)把它变成了一个验证步骤——不带凭据 `curl` 一个对象端点,坚持要看到带机器可读 `code` 的 `401`——并把那里出现 `200` 当作事故处理。所以这个问题诚实的问法是:既然数据平面从结构上就对匿名调用者关闭,公开表单到底是怎么进来的? + +## 公开表单是一份声明,不是一条权限 + +下面是 CRM 示例 web-to-lead 表单的全部内容,照 17.7.0 标签里发布的原样: + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +里面没有一个字提到权限。开门的是 `sharing` 块:`enabled`、`allowAnonymous` 和 `publicLink` 三者齐备时,运行时注册两个匿名端点——`GET /api/v1/forms/contact-us` 返回解析后的表单和白名单 schema,`POST /api/v1/forms/contact-us/submit` 插入一条 `crm_lead`。文档用一句话规定访客能写什么:「Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side」(提交时只接受 `sections[].fields` 里列出的字段;其余一切在服务端剥除)。而它给出的这样设计的理由,正是本文要验证的那句话:「Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form」(授权是从声明推导出来的,不是单独配置的——所以授权不可能漂移得比表单更宽)。 + +![两道网:路由只保留五个声明字段并丢弃服务端管理的名字;数据层只允许在 crm_lead 上插入并读回,再次剥除同样的名字。读取答 401;撤回的表单答 404。](./two-nets.svg) + +从机制上说有两道网,值得分别起名,因为审阅者可以逐一核对。 + +**第一道网是路由。** 提交处理器用匹配到的表单的 `sections[].fields` 构建允许列表,只把这些键从请求体里拷出来。在此之前,它按名字丢掉 `PUBLIC_FORM_SERVER_MANAGED_FIELDS` 里的每一个键——这是 spec 包导出的一个集合:`id`、`owner_id`、`owning_business_unit_id`、`organization_id`、`tenant_id`、`created_at`、`created_by`、`updated_at`、`updated_by`、`is_deleted`、`deleted_at` 和 `__search`——十二个名字,文档说它们「**never** accepted from a public submission — not even when a section declares them」(永远不从公开提交接受——即使某个分区声明了它们也不接受)。一个一个字段都没有的表单会以 `400` 拒绝,而不是来什么收什么。 + +**第二道网是数据层。** 路由给这次写入附上一个随请求而生的 `publicFormGrant: { object: 'crm_lead' }`,安全插件只认这个:`insert`,加上读回所需的 `find`、`findOne` 和 `count`,而且仅限这一个对象。在放行插入之前,它把同样的十二个名字再剥一次,所以即使某个表单在分区里声明了 `owner_id`,或者路由过滤器和数据层将来被改得不一致,访客也伪造不了归属和租户位置。这份授权就是表单的形状,在请求时读出来。权限表里没有一行写着「匿名可以写线索」,因为根本没有什么可配置的。 + +## 17.7.0 上的实测记录 + +我从 `@objectstack/cli@17.7.0` 标签取出 `examples/app-crm`,把依赖钉到已发布的 17.7.0 包,用 `objectstack dev` 启动。开发服务器在空数据库上会播种一个管理员账号;预置的五条线索归它所有。下面每个响应都是真实的,只为篇幅做了裁剪。 + +**1. 这道门会自我描述——五个字段宽。** 不带任何凭据: + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +`objectSchema` 恰好只带五个白名单字段。`crm_lead` 的对象定义声明了十三个业务字段;公开 schema 只显示五个,对 `status`、`lead_score`、`assigned_to`、`account`、`notes` 和其余字段只字不提。访客连该试什么都无从得知。 + +**2. 一次干净的提交创建一行。** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"` 并没有被发送;它是对象的写路径默认值,线索对象声明它的理由写得很明确:「so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required`」(让最小化创建——比如不暴露 status 的公开 Web-to-Lead 表单——也能满足 `required`)。`source: "web"` 和 `lead_score: 0` 同样是默认值。 + +**3. 一次恶意的提交创建的是同样的一行。** 发了六个键,两个在表单上: + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +以管理员身份读回,存储的行一模一样:两个白名单字段到了,两个不在表单上的业务字段被丢弃,两个系统锚点先被丢弃、再由服务端盖章——`organization_id` 是这个部署自己的组织,不是 `org_other`;`owner_id` 是 `null`。`201` 不会说明哪些键被拒绝;路由一声不响地把它们丢掉。 + +| 发送的键 | 在表单上? | 存储为 | 原因 | +|:--|:--|:--|:--| +| `name`、`email` | 是 | 照发送的值 | 在白名单内 | +| `status: "converted"` | 否 | `"new"` | 不在 `sections[].fields` 里;对象默认值生效 | +| `lead_score: 100` | 否 | `0` | 不在 `sections[].fields` 里;对象默认值生效 | +| `owner_id: "attacker"` | 否 | `null` | 服务端管理的名字,两道网都拒绝 | +| `organization_id: "org_other"` | 否 | 服务端自己的值 | 服务端管理的名字;由运行时盖章 | + +**4. 这道门不向另一边打开。** 同一个匿名客户端,这次来读: + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +两个都答 `401`——列表,以及访客一秒钟前刚创建的那一行。`201` 的响应体是提交者唯一一次看到这条记录的机会。「The public path only ever **inserts**; it never lists」(公开路径只会插入,从不列出)。 + +**5. 撤回表单会同时关闭两个端点。** 保留 `publicLink`,把 `enabled` 设为 `false`,重新构建,重启: + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +以管理员身份读到的线索总数前后都是 15。四个状态码讲完了这道门的全部故事:`201`、`201`、`401`、`404`。 + +## 哪个说法在承重:guest 配置文件,还是声明? + +在这里文档和自带示例说法不一,而我不想靠读代码来选边。指南说:「Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant」(声明一个公开表单视图;提交路由从这份声明推导一份窄授权。不需要 `guest` 配置文件,不需要宽泛授权)。CRM 示例却附带了一个 `GuestPortalProfile`——一个名为 `guest_portal` 的权限集,在 `crm_lead` 上 `allowCreate: true`,其余位全为 false——表单上方的注释说「`sharing.allowAnonymous` 打开公开端点;`guest_portal` 配置文件(对 crm_lead 仅 INSERT)授权这次写入」。运行时自己在 17.7.0 的源码里,相隔几百行同时持有两种说法:路由的文档注释说安全「委托给执行上下文上携带的 `guest_portal` 权限集」,并警告没有它中间件会「fall open」(失守放行);而紧接其后的处理器代码说公开表单「work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal`」(在默认安全、拒绝匿名的姿态下,无需部署配置 `guest_portal` 也能工作),把这个配置文件名留在上下文里只是「for back-compat with object hooks」(为了与对象钩子向后兼容)。 + +于是我把 `GuestPortalProfile` 从 stack 的 `permissions` 里删掉,重新构建。第一件发生的事是构建拒绝了: + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +这道门禁是另一个话题;在这里它是一句令人安心的旁白——一个权限集的消失被当作需要审阅的变更,哪怕这个集合最后被证明什么都不干。我接受了这次漂移,确认编译出的工件里只剩一个权限集而不是两个,然后把四个探测重新跑了一遍。 + +| 探测 | 有 `guest_portal` | 没有 `guest_portal` | +|:--|:--|:--| +| 带 `name`、`email` 的 `POST …/submit` | `201`,创建一行 | `201`,创建一行 | +| 夹带 `status`、`owner_id`、`organization_id` 的 `POST …/submit` | `201`;`status: "new"`、`owner_id: null`、服务端的 `organization_id` | 完全相同 | +| 匿名 `GET /api/v1/data/crm_lead` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| 匿名 `GET` 刚创建的那一行 | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +逐行一致。在 17.7.0 上,承重的是声明,这个配置文件在这条路径上是惰性的。实际意义是:不要为了让公开表单工作而添加 guest 配置文件。它给你的访问矩阵加了一份公开之门从不查询的常设授权,还向下一位审阅者——不管是人还是 Agent——讲了一个运行时并不认同的「边界在哪里」的故事。示例里的那条注释对 17.7.0 来说是错的,我已经如实上报;在它改掉之前,读指南,别读注释。 + +示例和指南还有一处分道扬镳:指南的第三步是「Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation」(把对象的 `sharingModel` 设为 `'private'`,让提交在创建后只对内部人员可见),而 CRM 的线索对象刻意用了 `public_read_write`,注释解释说这个演示有意按组织共享。这不会削弱这道门——组织级默认值管的是**已登录**用户彼此能看到多少行,匿名调用者在那之前就已经被拒绝——但对你自己的录入对象,这条建议值得照做。 + +## 这道门做不到的事 + +- **没有限流、没有验证码、没有反垃圾。** 公开表单路径没有任何相关文档,我也没有找到任何实现:本文的探测在没有任何凭据的情况下创建了十条线索,运行时既没有计数也没有减速。把常规的边缘防护放在 `/api/v1/forms/*` 前面;运行时守的是写进去什么,不是写得多频繁。 +- **表单上的 `required` 在公开提交时不被强制。** web-to-lead 表单把 `email` 标为必填;不带它的 `POST` 答 `201`,`email: null`。对象上的 `required` 会被强制——不带 `name` 的 `POST` 答 `400 VALIDATION_FAILED`,附「Lead Name is required」。如果一个字段必须存在,就在对象上要求它,或者在钩子里检查;在这条路径上,表单的标记只是渲染提示。 +- **`201` 回显整行。** 授权允许的读回就是访客创建的那一行,包括服务端盖章的值和默认值——`organization_id` 也在其中。我的这次没有任何敏感内容。如果你的录入对象带有默认值有实际含义的内部字段,就假定提交者会看到它们一次。对象上的 `beforeInsert` 或 `afterInsert` 钩子盖上去的任何值也是一样,包括它从已有记录推导出来的值。在 17.7.0 上,把会查询已有记录的钩子——去重、轮询分派——放在公开录入对象的插入路径之外,录入之后再做这类路由,比如放进流程或定时任务。 +- **所有者是「无人」。** 公开提交的 `owner_id` 保持 `null`;安全插件把它留给「object hooks / the first-admin bootstrap to assign」(对象钩子或首位管理员引导流程来分配)。如果你的流程要分派线索,在钩子或流程里做——别指望这一行天然属于某个人。 +- **撤回需要重新构建——忘了的话 `os dev` 会提醒你。** 我第五步的第一次尝试什么都没变,因为 `objectstack dev --no-watch` 提供的是上一次的 `dist/objectstack.json`,并在日志第五行明说了:「dist/objectstack.json is OLDER than your sources — this boot serves the STALE build」(dist/objectstack.json 比你的源码旧——本次启动提供的是过期构建)。它打印的修法有效。我提这件事,是因为一个只读状态码的 Agent 驱动循环会得出「这个开关不起作用」的结论。 +- **跨元数据层的撤回有本文没走到的边缘。** 组织保存的视图副本、包随附的表单,在指南中各有规则。这里测的单个开关,是大多数应用需要的那一个。 + +## 审阅者的五个问题 + +不管表单是人写的还是 Agent 写的,审阅是一样的,而且很短,因为授权就是表单。 + +1. **哪些表单是公开的?** 每一个 `sharing.enabled`、`sharing.allowAnonymous` 和 `publicLink` 三者齐备的表单视图都是一个匿名端点。编译出的工件列出了它们;没有别的地方可查。 +2. **`sections[].fields` 里有什么?** 这份列表减去十二个服务端管理的名字,就是公开写入面。像读防火墙规则那样读它。 +3. **对象会补上表单没收的东西吗?** 表单不收集的每一个必填字段都需要对象级默认值——示例里的 `status`——否则提交失败。而每一个必须存在的字段,`required` 要写在对象上,不是表单上。 +4. **哪里有常设的匿名授权吗?** 绑定到 `guest` 或 `everyone` 岗位的权限集,或者「为了让表单工作」而添加的 `guest_portal` 集合,既不需要,也不会拓宽这道门用到的任何东西。把它的存在当成一个问题。 +5. **门前有什么?** 限流和机器人防护是部署的工作。 + +这和[Agent 权限在哪里被强制](/zh-Hans/blog/objectos-agent-permission-boundaries/)是同一个「在何处执行」的论证,只是套在一个完全没有身份的调用者身上:检查在路由和数据层里跑,而不是在恰好把字段藏起来的 UI 里。它也是[那篇 Lovable 文章](/zh-Hans/blog/is-lovable-safe-for-production/)描述的审阅难题的答案——负责任的所有者能读懂五行字段列表并签字,这对一份生成出来的策略可做不到。术语表里的[权限模型](/zh-Hans/glossary/permission-model/)条目有总体的形状;这道门是「默认拒绝」唯一一个有意为之的例外,而它的形状就是表单。 + +## 把你的 Agent 指向它 + +如果你的应用由 Agent 来写,该交给它的不是这篇文章,而是这条规则:公开表单是一个带 `sharing` 块的 `formViews` 条目,字段列表就是边界,默认值和必填由对象负责,不涉及任何权限集。[为 ObjectStack 配置编码 Agent 的指南](/en/blog/how-to-point-your-agent-at-objectstack/)讲了这条规则如何到达 Agent——规则文件、运行中的应用暴露的 MCP 端点,以及 spec。`npm create objectstack@latest` 装的是 17.7.0,正是这份记录运行的版本。 + +想把同一个循环托管起来、在浏览器里用、什么都不用安装?那就是 ObjectOS——建立在 ObjectStack 之上的商业运行环境。 diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.zh-Hant.mdx b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.zh-Hant.mdx new file mode 100644 index 0000000..2707689 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/index.zh-Hant.mdx @@ -0,0 +1,212 @@ +--- +# @generated zh-Hant from zh-Hans (s2twp) — edit the zh-Hans file; delete this line to hand-maintain. +title: 怎樣讓匿名訪客把表單提交進一個受治理的業務應用?一道公開的門,五個欄位寬 +description: v17 起匿名訪問物件資料一律拒絕。唯一開著的門是公開表單檢視:執行時從表單自身推導授權——建立一行、讀回這一行、五個欄位,僅此而已。在 17.7.0 上實測。 +author: ObjectStack Team +date: 2026-10-22 +status: published +topic: app-building +audience: business +solutions: [] +industries: [] +cover: ./cover.svg +tags: + - 公開表單 + - Web-to-Lead + - 匿名訪問 +--- + +**先給結論**:從 v17 起,ObjectStack 應用裡任何未經認證的物件資料請求都會被 `401 UNAUTHENTICATED` 拒絕——每個物件、每個端點,沒有例外開關。一個受治理的應用向公眾留下的唯一一道門,是**公開表單檢視**:宣告一個表單,把它的 `sharing` 塊標為 `allowAnonymous: true`,執行時就從這份宣告推匯出訪客的全部授權——在那一個物件上建立一條記錄,讀回它剛建立的那一行,別的什麼都不能做。表單的欄位列表就是安全邊界。CRM 示例的 web-to-lead 表單列了五個欄位,於是五個欄位能進來;其餘八個業務欄位和每一個系統列都進不來,不管訪客發了什麼。我把它稱為**表單形狀的授權**(form-shaped grant):授權在請求時從表單算出來,所以它不可能被配置得比表單更寬。下文的每一項都在已釋出的 `@objectstack/cli` 17.7.0 上實測,也就是今天 `npm create objectstack@latest` 裝上的版本——包括文件和自帶示例唯一一處說法不一致的問題:到底需不需要一個 `guest_portal` 配置檔案。答案是不需要。 + +## 問題,用文件自己的話說 + +ObjectStack 關於公開資料收集的指南,開頭就用使用者的口吻寫下了場景: + +> 我想讓匿名訪客提交一個表單——「聯絡我們」「申請演示」或者錄入表單——它會建立一條記錄。但我絕對**不能**把底層資料開放給他們,而且他們只能設定表單上的那些欄位。 + +每個業務應用都有這麼一張表:網站上的線索表單、支援工單錄入、演示申請、保修申請。常見的幾種做法各有代價。給一個「guest」角色加上對這張表的寫權限,是一份沒人會再讀的常設授權,它會漂移。手寫一個端點,繞開了治理其他一切的權限模型。用第三方表單工具加 webhook,意味著現在有兩套系統各持一份 schema,而審閱者看的不是其中那一份。 + +從 v17 起,第一種做法根本不存在了。權限文件順帶記錄了原因:「since v17 anonymous access to object data is always denied anyway — the `requireAuth: false` opt-out was retired」(自 v17 起匿名訪問物件資料本來就一律拒絕——`requireAuth: false` 這個退出開關已經退役)。[自託管實操](/en/blog/how-to-self-host-objectstack/)把它變成了一個驗證步驟——不帶憑據 `curl` 一個物件端點,堅持要看到帶機器可讀 `code` 的 `401`——並把那裡出現 `200` 當作事故處理。所以這個問題誠實的問法是:既然資料平面從結構上就對匿名呼叫者關閉,公開表單到底是怎麼進來的? + +## 公開表單是一份宣告,不是一條權限 + +下面是 CRM 示例 web-to-lead 表單的全部內容,照 17.7.0 標籤裡釋出的原樣: + +```ts +formViews: { + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + name: 'contact_us', + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, +}, +``` + +裡面沒有一個字提到權限。開門的是 `sharing` 塊:`enabled`、`allowAnonymous` 和 `publicLink` 三者齊備時,執行時註冊兩個匿名端點——`GET /api/v1/forms/contact-us` 返回解析後的表單和白名單 schema,`POST /api/v1/forms/contact-us/submit` 插入一條 `crm_lead`。文件用一句話規定訪客能寫什麼:「Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side」(提交時只接受 `sections[].fields` 裡列出的欄位;其餘一切在服務端剝除)。而它給出的這樣設計的理由,正是本文要驗證的那句話:「Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form」(授權是從宣告推匯出來的,不是單獨配置的——所以授權不可能漂移得比表單更寬)。 + +![兩道網:路由只保留五個宣告欄位並丟棄服務端管理的名字;資料層只允許在 crm_lead 上插入並讀回,再次剝除同樣的名字。讀取答 401;撤回的表單答 404。](./two-nets.svg) + +從機制上說有兩道網,值得分別起名,因為審閱者可以逐一核對。 + +**第一道網是路由。** 提交處理器用匹配到的表單的 `sections[].fields` 構建允許列表,只把這些鍵從請求體裡拷出來。在此之前,它按名字丟掉 `PUBLIC_FORM_SERVER_MANAGED_FIELDS` 裡的每一個鍵——這是 spec 包匯出的一個集合:`id`、`owner_id`、`owning_business_unit_id`、`organization_id`、`tenant_id`、`created_at`、`created_by`、`updated_at`、`updated_by`、`is_deleted`、`deleted_at` 和 `__search`——十二個名字,文件說它們「**never** accepted from a public submission — not even when a section declares them」(永遠不從公開提交接受——即使某個分割槽聲明瞭它們也不接受)。一個一個欄位都沒有的表單會以 `400` 拒絕,而不是來什麼收什麼。 + +**第二道網是資料層。** 路由給這次寫入附上一個隨請求而生的 `publicFormGrant: { object: 'crm_lead' }`,安全外掛只認這個:`insert`,加上讀回所需的 `find`、`findOne` 和 `count`,而且僅限這一個物件。在放行插入之前,它把同樣的十二個名字再剝一次,所以即使某個表單在分割槽裡聲明瞭 `owner_id`,或者路由過濾器和資料層將來被改得不一致,訪客也偽造不了歸屬和租戶位置。這份授權就是表單的形狀,在請求時讀出來。權限表裡沒有一行寫著「匿名可以寫線索」,因為根本沒有什麼可配置的。 + +## 17.7.0 上的實測記錄 + +我從 `@objectstack/cli@17.7.0` 標籤取出 `examples/app-crm`,把依賴釘到已釋出的 17.7.0 包,用 `objectstack dev` 啟動。開發伺服器在空資料庫上會播種一個管理員賬號;預置的五條線索歸它所有。下面每個響應都是真實的,只為篇幅做了裁剪。 + +**1. 這道門會自我描述——五個欄位寬。** 不帶任何憑據: + +```bash +curl -s http://localhost:49160/api/v1/forms/contact-us +``` + +```json +{ "slug": "contact-us", "object": "crm_lead", + "form": { "sections": [ { "name": "contact_us", "fields": [ + { "field": "name", "required": true }, { "field": "company" }, + { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ], + "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } }, + "objectSchema": { "name": "crm_lead", "fields": { "name": {}, "email": {}, "phone": {}, "company": {}, "title": {} } } } +``` + +`objectSchema` 恰好只帶五個白名單欄位。`crm_lead` 的物件定義聲明瞭十三個業務欄位;公開 schema 只顯示五個,對 `status`、`lead_score`、`assigned_to`、`account`、`notes` 和其餘欄位隻字不提。訪客連該試什麼都無從得知。 + +**2. 一次乾淨的提交建立一行。** + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +``` + +```json +HTTP 201 +{ "object": "crm_lead", "id": "lY0qNhApoHwyYMtk", + "record": { "id": "lY0qNhApoHwyYMtk", "name": "Ada Lovelace", "email": "ada@example.com", + "status": "new", "source": "web", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e", "created_by": null } } +``` + +`status: "new"` 並沒有被髮送;它是物件的寫路徑預設值,線索物件宣告它的理由寫得很明確:「so minimal creates (e.g. the public Web-to-Lead form, which doesn't expose status) satisfy `required`」(讓最小化建立——比如不暴露 status 的公開 Web-to-Lead 表單——也能滿足 `required`)。`source: "web"` 和 `lead_score: 0` 同樣是預設值。 + +**3. 一次惡意的提交建立的是同樣的一行。** 發了六個鍵,兩個在表單上: + +```bash +curl -s -X POST http://localhost:49160/api/v1/forms/contact-us/submit \ + -H 'content-type: application/json' \ + -d '{"name":"Mallory","email":"mallory@example.com", + "status":"converted","lead_score":100, + "owner_id":"attacker","organization_id":"org_other"}' +``` + +```json +HTTP 201 +{ "record": { "id": "OJINuRoRVtZC-l95", "name": "Mallory", "email": "mallory@example.com", + "status": "new", "lead_score": 0, "owner_id": null, + "organization_id": "org_mvbpxfocznjyqv4e" } } +``` + +以管理員身份讀回,儲存的行一模一樣:兩個白名單欄位到了,兩個不在表單上的業務欄位被丟棄,兩個系統錨點先被丟棄、再由服務端蓋章——`organization_id` 是這個部署自己的組織,不是 `org_other`;`owner_id` 是 `null`。`201` 不會說明哪些鍵被拒絕;路由一聲不響地把它們丟掉。 + +| 傳送的鍵 | 在表單上? | 儲存為 | 原因 | +|:--|:--|:--|:--| +| `name`、`email` | 是 | 照發送的值 | 在白名單內 | +| `status: "converted"` | 否 | `"new"` | 不在 `sections[].fields` 裡;物件預設值生效 | +| `lead_score: 100` | 否 | `0` | 不在 `sections[].fields` 裡;物件預設值生效 | +| `owner_id: "attacker"` | 否 | `null` | 服務端管理的名字,兩道網都拒絕 | +| `organization_id: "org_other"` | 否 | 服務端自己的值 | 服務端管理的名字;由執行時蓋章 | + +**4. 這道門不向另一邊開啟。** 同一個匿名客戶端,這次來讀: + +```bash +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead +curl -s -w '\n%{http_code}\n' http://localhost:49160/api/v1/data/crm_lead/OJINuRoRVtZC-l95 +``` + +```json +{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."} +401 +``` + +兩個都答 `401`——列表,以及訪客一秒鐘前剛建立的那一行。`201` 的響應體是提交者唯一一次看到這條記錄的機會。「The public path only ever **inserts**; it never lists」(公開路徑只會插入,從不列出)。 + +**5. 撤回表單會同時關閉兩個端點。** 保留 `publicLink`,把 `enabled` 設為 `false`,重新構建,重啟: + +```json +GET /api/v1/forms/contact-us → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +POST /api/v1/forms/contact-us/submit → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"} +``` + +以管理員身份讀到的線索總數前後都是 15。四個狀態碼講完了這道門的全部故事:`201`、`201`、`401`、`404`。 + +## 哪個說法在承重:guest 配置檔案,還是宣告? + +在這裡文件和自帶示例說法不一,而我不想靠讀程式碼來選邊。指南說:「Declare a public form view; the submit route derives a narrow authorization from that declaration. No `guest` profile, no broad grant」(宣告一個公開表單檢視;提交路由從這份宣告推導一份窄授權。不需要 `guest` 配置檔案,不需要寬泛授權)。CRM 示例卻附帶了一個 `GuestPortalProfile`——一個名為 `guest_portal` 的權限集,在 `crm_lead` 上 `allowCreate: true`,其餘位全為 false——表單上方的註釋說「`sharing.allowAnonymous` 開啟公開端點;`guest_portal` 配置檔案(對 crm_lead 僅 INSERT)授權這次寫入」。執行時自己在 17.7.0 的原始碼裡,相隔幾百行同時持有兩種說法:路由的文件註釋說安全「委託給執行上下文上攜帶的 `guest_portal` 權限集」,並警告沒有它中介軟體會「fall open」(失守放行);而緊接其後的處理器程式碼說公開表單「work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured `guest_portal`」(在預設安全、拒絕匿名的姿態下,無需部署配置 `guest_portal` 也能工作),把這個配置檔名留在上下文裡只是「for back-compat with object hooks」(為了與物件鉤子向後相容)。 + +於是我把 `GuestPortalProfile` 從 stack 的 `permissions` 裡刪掉,重新構建。第一件發生的事是構建拒絕了: + +```text +✗ Access matrix drift (1 change) — capability changes must be reviewed +• 'guest_portal' loses ALL access to 'crm_lead' (entry removed) + If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact. +``` + +這道門禁是另一個話題;在這裡它是一句令人安心的旁白——一個權限集的消失被當作需要審閱的變更,哪怕這個集合最後被證明什麼都不幹。我接受了這次漂移,確認編譯出的工件裡只剩一個權限集而不是兩個,然後把四個探測重新跑了一遍。 + +| 探測 | 有 `guest_portal` | 沒有 `guest_portal` | +|:--|:--|:--| +| 帶 `name`、`email` 的 `POST …/submit` | `201`,建立一行 | `201`,建立一行 | +| 夾帶 `status`、`owner_id`、`organization_id` 的 `POST …/submit` | `201`;`status: "new"`、`owner_id: null`、服務端的 `organization_id` | 完全相同 | +| 匿名 `GET /api/v1/data/crm_lead` | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | +| 匿名 `GET` 剛建立的那一行 | `401 UNAUTHENTICATED` | `401 UNAUTHENTICATED` | + +逐行一致。在 17.7.0 上,承重的是宣告,這個配置檔案在這條路徑上是惰性的。實際意義是:不要為了讓公開表單工作而新增 guest 配置檔案。它給你的訪問矩陣加了一份公開之門從不查詢的常設授權,還向下一位審閱者——不管是人還是 Agent——講了一個執行時並不認同的「邊界在哪裡」的故事。示例裡的那條註釋對 17.7.0 來說是錯的,我已經如實上報;在它改掉之前,讀指南,別讀註釋。 + +示例和指南還有一處分道揚鑣:指南的第三步是「Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation」(把物件的 `sharingModel` 設為 `'private'`,讓提交在建立後只對內部人員可見),而 CRM 的線索物件刻意用了 `public_read_write`,註釋解釋說這個演示有意按組織共享。這不會削弱這道門——組織級預設值管的是**已登入**使用者彼此能看到多少行,匿名呼叫者在那之前就已經被拒絕——但對你自己的錄入物件,這條建議值得照做。 + +## 這道門做不到的事 + +- **沒有限流、沒有驗證碼、沒有反垃圾。** 公開表單路徑沒有任何相關文件,我也沒有找到任何實現:本文的探測在沒有任何憑據的情況下建立了十條線索,執行時既沒有計數也沒有減速。把常規的邊緣防護放在 `/api/v1/forms/*` 前面;執行時守的是寫進去什麼,不是寫得多頻繁。 +- **表單上的 `required` 在公開提交時不被強制。** web-to-lead 表單把 `email` 標為必填;不帶它的 `POST` 答 `201`,`email: null`。物件上的 `required` 會被強制——不帶 `name` 的 `POST` 答 `400 VALIDATION_FAILED`,附「Lead Name is required」。如果一個欄位必須存在,就在物件上要求它,或者在鉤子裡檢查;在這條路徑上,表單的標記只是渲染提示。 +- **`201` 回顯整行。** 授權允許的讀回就是訪客建立的那一行,包括服務端蓋章的值和預設值——`organization_id` 也在其中。我的這次沒有任何敏感內容。如果你的錄入物件帶有預設值有實際含義的內部欄位,就假定提交者會看到它們一次。物件上的 `beforeInsert` 或 `afterInsert` 鉤子蓋上去的任何值也是一樣,包括它從已有記錄推匯出來的值。在 17.7.0 上,把會查詢已有記錄的鉤子——去重、輪詢分派——放在公開錄入物件的插入路徑之外,錄入之後再做這類路由,比如放進流程或定時任務。 +- **所有者是「無人」。** 公開提交的 `owner_id` 保持 `null`;安全外掛把它留給「object hooks / the first-admin bootstrap to assign」(物件鉤子或首位管理員引導流程來分配)。如果你的流程要分派線索,在鉤子或流程裡做——別指望這一行天然屬於某個人。 +- **撤回需要重新構建——忘了的話 `os dev` 會提醒你。** 我第五步的第一次嘗試什麼都沒變,因為 `objectstack dev --no-watch` 提供的是上一次的 `dist/objectstack.json`,並在日誌第五行明說了:「dist/objectstack.json is OLDER than your sources — this boot serves the STALE build」(dist/objectstack.json 比你的原始碼舊——本次啟動提供的是過期構建)。它列印的修法有效。我提這件事,是因為一個只讀狀態碼的 Agent 驅動迴圈會得出「這個開關不起作用」的結論。 +- **跨後設資料層的撤回有本文沒走到的邊緣。** 組織儲存的檢視副本、包隨附的表單,在指南中各有規則。這裡測的單個開關,是大多數應用需要的那一個。 + +## 審閱者的五個問題 + +不管表單是人寫的還是 Agent 寫的,審閱是一樣的,而且很短,因為授權就是表單。 + +1. **哪些表單是公開的?** 每一個 `sharing.enabled`、`sharing.allowAnonymous` 和 `publicLink` 三者齊備的表單檢視都是一個匿名端點。編譯出的工件列出了它們;沒有別的地方可查。 +2. **`sections[].fields` 裡有什麼?** 這份列表減去十二個服務端管理的名字,就是公開寫入面。像讀防火牆規則那樣讀它。 +3. **物件會補上表單沒收的東西嗎?** 表單不收集的每一個必填欄位都需要物件級預設值——示例裡的 `status`——否則提交失敗。而每一個必須存在的欄位,`required` 要寫在物件上,不是表單上。 +4. **哪裡有常設的匿名授權嗎?** 繫結到 `guest` 或 `everyone` 崗位的權限集,或者「為了讓表單工作」而新增的 `guest_portal` 集合,既不需要,也不會拓寬這道門用到的任何東西。把它的存在當成一個問題。 +5. **門前有什麼?** 限流和機器人防護是部署的工作。 + +這和[Agent 權限在哪裡被強制](/zh-Hant/blog/objectos-agent-permission-boundaries/)是同一個「在何處執行」的論證,只是套在一個完全沒有身份的呼叫者身上:檢查在路由和資料層裡跑,而不是在恰好把欄位藏起來的 UI 裡。它也是[那篇 Lovable 文章](/zh-Hant/blog/is-lovable-safe-for-production/)描述的審閱難題的答案——負責任的所有者能讀懂五行欄位列表並簽字,這對一份生成出來的策略可做不到。術語表裡的[權限模型](/zh-Hant/glossary/permission-model/)條目有總體的形狀;這道門是「預設拒絕」唯一一個有意為之的例外,而它的形狀就是表單。 + +## 把你的 Agent 指向它 + +如果你的應用由 Agent 來寫,該交給它的不是這篇文章,而是這條規則:公開表單是一個帶 `sharing` 塊的 `formViews` 條目,欄位列表就是邊界,預設值和必填由物件負責,不涉及任何權限集。[為 ObjectStack 配置編碼 Agent 的指南](/en/blog/how-to-point-your-agent-at-objectstack/)講了這條規則如何到達 Agent——規則檔案、執行中的應用暴露的 MCP 端點,以及 spec。`npm create objectstack@latest` 裝的是 17.7.0,正是這份記錄執行的版本。 + +想把同一個迴圈託管起來、在瀏覽器裡用、什麼都不用安裝?那就是 ObjectOS——建立在 ObjectStack 之上的商業執行環境。 diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/two-nets-en.svg b/content/blog/how-to-let-anonymous-visitors-submit-a-form/two-nets-en.svg new file mode 100644 index 0000000..c086c03 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/two-nets-en.svg @@ -0,0 +1,123 @@ + + + + + + + + + + One grant, shaped like the form: two nets between the visitor and the row + examples/app-crm, web_to_lead, @objectstack/cli 17.7.0 — every value below is from the transcript + + + + + ANONYMOUS VISITOR + POST …/contact-us/submit + No session, no key. Body: + name: "Mallory" + email: "mallory@…" + status: "converted" + lead_score: 100 + owner_id: "attacker" + organization_id: "org_other" + Six keys. Two are on the form. + Two are business fields + the form never declared. + + + + + + + + NET 1 — THE ROUTE + Allow-list = the form + sections[].fields of the matched + form: five names. A form with no + fields is refused with 400. + Twelve server-managed names are + dropped first, whatever is declared. + name passes + email passes + status dropped + lead_score dropped + owner_id denied by name + organization_id denied by name + + + + + + + + NET 2 — THE DATA LAYER + publicFormGrant + { object: 'crm_lead' } + Request-scoped. Admits insert, + plus find / findOne / count for + the read-back, on this object only. + Strips the same twelve names + again before the write is admitted. + No permission set is consulted. + Removing guest_portal changed + nothing: measured, not assumed. + The grant is read off the form at + request time. It cannot be wider. + + + + + + + + STORED crm_lead ROW + 201 Created + name Mallory + email mallory@… + status new + lead_score 0 + owner_id null + organization_id + stamped by server + status and lead_score are + the object's own defaults. + The echo is the only read. + + + + + + THE DOOR DOES NOT OPEN THE OTHER WAY + GET /api/v1/data/crm_lead + GET /api/v1/data/crm_lead/OJINuRoRVtZC-l95 + 401 + UNAUTHENTICATED + The list, and the row the visitor created a second + earlier. The public path only ever inserts; it never lists. + + + + + WITHDRAWING THE FORM + sharing: { enabled: false, publicLink: '/forms/contact-us' } + GET …/forms/contact-us POST …/forms/contact-us/submit + 404 + FORM_NOT_FOUND + Both endpoints close; the lead count stays at 15. + Takes a rebuild — os dev warns when it serves a stale artifact. + + diff --git a/content/blog/how-to-let-anonymous-visitors-submit-a-form/two-nets.svg b/content/blog/how-to-let-anonymous-visitors-submit-a-form/two-nets.svg new file mode 100644 index 0000000..0ac1a22 --- /dev/null +++ b/content/blog/how-to-let-anonymous-visitors-submit-a-form/two-nets.svg @@ -0,0 +1,118 @@ + + + + + + + + + + 一份授权,形状即表单:访客与数据行之间的两道网 + examples/app-crm,web_to_lead,@objectstack/cli 17.7.0 —— 下方每个值都来自实测记录 + + + + 匿名访客 + POST …/contact-us/submit + 无会话,无密钥。请求体: + name: "Mallory" + email: "mallory@…" + status: "converted" + lead_score: 100 + owner_id: "attacker" + organization_id: "org_other" + 六个键,两个在表单上。 + 两个是表单从未声明的 + 业务字段。 + + + + + + + 第一道网 —— 路由 + 白名单 = 表单本身 + 取匹配表单的 sections[].fields: + 五个名字。没有字段的表单 + 直接以 400 拒绝。 + 十二个服务端管理的名字先被 + 丢弃,不论表单怎么声明。 + name 通过 + email 通过 + status 丢弃 + lead_score 丢弃 + owner_id 按名字拒绝 + organization_id 按名字拒绝 + + + + + + + 第二道网 —— 数据层 + publicFormGrant + { object: 'crm_lead' } + 随请求而生。只允许 insert, + 以及读回所需的 find / findOne + / count,且仅限这个对象。 + 写入放行之前,再把同样的 + 十二个名字剥除一次。 + 不查询任何权限集。 + 移除 guest_portal 后一切不变: + 是实测,不是推断。 + 授权在请求时从表单读出, + 所以不可能比表单更宽。 + + + + + + + 存入的 crm_lead 行 + 201 Created + name Mallory + email mallory@… + status new + lead_score 0 + owner_id null + organization_id + 由服务端盖章 + status 与 lead_score 来自 + 对象自身的默认值。 + 这次回显是唯一一次读取。 + + + + + 这道门不向另一边打开 + GET /api/v1/data/crm_lead + GET /api/v1/data/crm_lead/OJINuRoRVtZC-l95 + 401 + UNAUTHENTICATED + 列表,以及访客一秒钟前刚创建的那一行,都被拒绝。 + 公开路径只会插入,从不列出。 + + + + + 撤回表单 + sharing: { enabled: false, publicLink: '/forms/contact-us' } + GET …/forms/contact-us POST …/forms/contact-us/submit + 404 + FORM_NOT_FOUND + 两个端点一起关闭;线索总数仍是 15。 + 需要重新构建 —— os dev 在提供过期产物时会明确警告。 + + diff --git a/content/blog/how-to-self-host-objectstack/index.mdx b/content/blog/how-to-self-host-objectstack/index.mdx index c8634f6..9b306dc 100644 --- a/content/blog/how-to-self-host-objectstack/index.mdx +++ b/content/blog/how-to-self-host-objectstack/index.mdx @@ -3,6 +3,7 @@ title: "How to Self-Host an AI App Platform: ObjectStack from Install to Verifie description: "A walkthrough of self-hosting the open-source ObjectStack runtime: the compiled-artifact model, the database driver everyone misses, how to prove the install is correct, and what you are now on call for." author: ObjectStack Team date: 2026-09-15T10:00:00+08:00 +updated: 2026-10-22 status: published topic: governance audience: it @@ -195,6 +196,8 @@ curl -s http://localhost:8080/api/v1/data/note A `200` with records here means your server is open to the internet. Treat it as an incident, not a configuration preference. +The one anonymous door a governed app does leave open — a public form view, whose grant is derived from the form's own declaration — is measured in [How Do You Let Anonymous Visitors Submit a Form Into a Governed Business App?](/en/blog/how-to-let-anonymous-visitors-submit-a-form/); it does not change this check, because the data endpoints stay closed. + Be clear about what this check does *not* prove: it passes whether or not you set `OS_AUTH_SECRET`, because the runtime mints one either way. It verifies the gate, not the key. And if you script it, assert the `code` field — `/data` and `/meta` answer with this flat shape, while dispatcher-served routes wrap the same denial as `{"success":false,"error":{"code":…}}`. Read the envelope the endpoint you called actually declares; a tolerant reader that accepts either is exactly where a future envelope regression would hide. **5. Claim the admin account before anyone else does.** A fresh production database has no users, and on a single-tenant deployment — the default, and what the banner's `Tenancy: single` line confirms — **the first account to register is promoted to platform admin**. Open the root URL and sign up immediately after the first deploy, before the URL is shared. The production server seeds no default credentials; the demo account you may know from `os dev` exists only on empty development databases. diff --git a/content/blog/is-lovable-safe-for-production/index.de.mdx b/content/blog/is-lovable-safe-for-production/index.de.mdx index 6eb2103..40ee302 100644 --- a/content/blog/is-lovable-safe-for-production/index.de.mdx +++ b/content/blog/is-lovable-safe-for-production/index.de.mdx @@ -3,6 +3,7 @@ title: "Ist Lovable produktionsreif? Das Problem prüfbarer Zugriffskontrolle" description: "Lovable ist stark für schnelle Prototypen. In Produktionssystemen zählt aber nicht nur, ob die App läuft, sondern wer die Zugriffskontrolle prüfen kann. RLS, Frontend-Filter und Security-Scans müssen sauber getrennt werden." author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -84,3 +85,5 @@ Wenn er diesen Test nicht durchführen kann, ihn nicht versteht oder die Antwort ObjectStack kehrt die Trennung um, die die Leaks verursacht. Zugriffskontrolle ist kein generiertes SQL, das der Erbauer nicht lesen kann; sie sind **deklarierte, lesbare Metadaten** — `read: owner == current_user` als Eigenschaft des Objekts — und sie sind eine **Grenze, die die Runtime serverseitig durchsetzt**, sodass das Entfernen eines Frontend-Filters nichts ändert. Wenn die KI eine Änderung daran vorschlägt, wer-was-lesen-kann, erscheint das als ein **Diff, das ein Nicht-Experte tatsächlich freigeben kann** („das macht `customers` für alle lesbar — bestätigen?“), niemals als ein stilles `using (true)`, das du hättest SQL lesen müssen, um es zu erwischen. Und weil es **selbst hostbar** ist und auf Systemen sitzt, die du ohnehin betreibst, landen sensible Daten nicht leise irgendwo, wo du sie nicht inspizieren kannst. Wir werden Lovable nicht bei der Zeit bis zur ersten Demo schlagen, und für Prototypen würden wir es nicht versuchen. Die Behauptung ist die, die in dem Augenblick zählt, in dem echte Daten im Spiel sind: Die Sicherheit einer KI-gebauten App muss *für die Person sichtbar sein, die dafür verantwortlich ist* — denn eine App, deren Grenze du nicht sehen kannst, ist nicht sicher, sondern nur noch nicht hart genug getestet. + +Die eine Tür, die eine governte App Aufrufern ganz ohne Identität offen lässt — eine öffentliche Formularansicht — ist in [Wie lässt man anonyme Besucher ein Formular in eine governte Business-App senden?](/de/blog/how-to-let-anonymous-visitors-submit-a-form/) gemessen: Das Recht wird aus der Deklaration des Formulars abgeleitet, fünf Felder breit, auf 17.7.0. diff --git a/content/blog/is-lovable-safe-for-production/index.es.mdx b/content/blog/is-lovable-safe-for-production/index.es.mdx index f22c34c..5387a02 100644 --- a/content/blog/is-lovable-safe-for-production/index.es.mdx +++ b/content/blog/is-lovable-safe-for-production/index.es.mdx @@ -3,6 +3,7 @@ title: "¿Lovable es seguro para producción? El problema de revisar el control description: "Lovable es fuerte para prototipos rápidos. En producción no basta con que la app funcione: alguien debe poder revisar el control de acceso. RLS, filtros de frontend y escáneres de seguridad no son lo mismo." author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -84,3 +85,5 @@ Si no puede hacer esa prueba, o no la entiende, o la respuesta es "las de todos" ObjectStack invierte la separación que causa las filtraciones. El control de acceso no es SQL generado que el constructor no sabe leer; son **metadatos declarados y legibles** —`read: owner == current_user` como propiedad del objeto— y son un **límite que el runtime aplica del lado del servidor**, de modo que quitar un filtro de frontend no cambia nada. Cuando la IA propone un cambio sobre quién-puede-leer-qué, aparece como un **diff que un no experto sí puede aprobar** ("esto hace que `customers` sea legible por todos —¿confirmas?"), nunca como un silencioso `using (true)` que habrías tenido que leer SQL para detectar. Y como es **autoalojable** y se asienta sobre sistemas que ya ejecutas, los datos sensibles no acaban silenciosamente en algún lugar que no puedes inspeccionar. No vamos a ganarle a Lovable en tiempo-hasta-la-primera-demo, y para prototipos no lo intentaríamos. La afirmación es la que importa en el instante en que hay datos reales en juego: la seguridad de una app construida por IA tiene que ser *visible para la persona responsable de ella* —porque una app cuyo límite no puedes ver no es segura, solo no ha sido probada con suficiente dureza—. + +La única puerta que una app gobernada deja abierta a llamadores sin identidad alguna — una vista de formulario pública — está medida en [¿Cómo dejar que visitantes anónimos envíen un formulario a una app de negocio gobernada?](/es/blog/how-to-let-anonymous-visitors-submit-a-form/): el permiso se deriva de la propia declaración del formulario, cinco campos de ancho, en 17.7.0. diff --git a/content/blog/is-lovable-safe-for-production/index.fr.mdx b/content/blog/is-lovable-safe-for-production/index.fr.mdx index 0ef804d..d547fd8 100644 --- a/content/blog/is-lovable-safe-for-production/index.fr.mdx +++ b/content/blog/is-lovable-safe-for-production/index.fr.mdx @@ -3,6 +3,7 @@ title: "Lovable est-il sûr pour la production ? Le problème du contrôle d'acc description: "Lovable est puissant pour prototyper vite. En production, le vrai sujet est de pouvoir relire le contrôle d'accès : RLS, filtres frontend et scanners de sécurité ne sont pas équivalents." author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -84,3 +85,5 @@ S'il ne peut pas faire ce test, ou ne le comprend pas, ou si la réponse est « ObjectStack inverse la séparation qui cause les fuites. Le contrôle d'accès n'est pas du SQL généré que le constructeur ne sait pas lire ; ce sont des **métadonnées déclarées et lisibles** — `read: owner == current_user` comme propriété de l'objet — et c'est une **frontière que le runtime applique côté serveur**, de sorte que retirer un filtre frontend ne change rien. Quand l'IA propose un changement sur qui-peut-lire-quoi, cela apparaît comme un **diff qu'un non-expert peut réellement approuver** (« ceci rend `customers` lisible par tout le monde — confirmer ? »), jamais comme un silencieux `using (true)` qu'il aurait fallu lire du SQL pour attraper. Et parce que c'est **auto-hébergeable** et que cela repose sur des systèmes que vous exécutez déjà, les données sensibles n'atterrissent pas discrètement quelque part que vous ne pouvez pas inspecter. Nous ne battrons pas Lovable sur le temps-jusqu'à-la-première-démo, et pour des prototypes nous n'essaierions pas. L'affirmation est celle qui compte à l'instant où de vraies données sont en jeu : la sécurité d'une app construite par IA doit être *visible pour la personne qui en répond* — parce qu'une app dont vous ne pouvez pas voir la frontière n'est pas sûre, elle est seulement non testée en public. + +La seule porte qu’une application gouvernée laisse ouverte à des appelants sans aucune identité — une vue de formulaire publique — est mesurée dans [Comment laisser des visiteurs anonymes soumettre un formulaire dans une application métier gouvernée ?](/fr/blog/how-to-let-anonymous-visitors-submit-a-form/) : le droit est dérivé de la déclaration du formulaire, large de cinq champs, sur 17.7.0. diff --git a/content/blog/is-lovable-safe-for-production/index.ja.mdx b/content/blog/is-lovable-safe-for-production/index.ja.mdx index d6b5774..e9a903b 100644 --- a/content/blog/is-lovable-safe-for-production/index.ja.mdx +++ b/content/blog/is-lovable-safe-for-production/index.ja.mdx @@ -3,6 +3,7 @@ title: "Lovable は本番運用に安全か:アクセス制御レビューの description: "Lovable は高速なプロトタイプに向いている。だが本番システムで問うべきは動くかではなく、誰がアクセス制御をレビューできるかだ。RLS、フロントエンドフィルタ、安全スキャンの境界を見極めたい。" author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -84,3 +85,5 @@ Lovableの名誉のために言えば、この批判は届き、彼らは応え ObjectStackは、漏洩を引き起こす分離を反転させる。アクセス制御は、作り手が読めない生成SQLではない。**宣言された、読めるメタデータ**だ——オブジェクトのプロパティとしての `read: owner == current_user`——そして**ランタイムがサーバー側で強制する境界**であり、フロントエンドのフィルタを外しても何も変わらない。AIが誰が何を読めるかの変更を提案すると、それは**非専門家が実際に承認できるdiff**として現れる(「これで `customers` が全員に読める状態になります——確認しますか?」)。SQLを読まねば気づけなかったであろう、沈黙の `using (true)` としては決して現れない。そして**セルフホスト可能**で、あなたがすでに動かしているシステムの上に乗るので、機微なデータが、あなたが点検できない場所へひそかに着地することはない。 我々は最初のデモまでの時間でLovableに勝つことはないし、プロトタイプでは勝とうともしない。主張は、実データが関わった瞬間に効いてくるものだ。AIが作ったアプリのセキュリティは、*それに責任を負う人に見えていなければならない*——なぜなら、境界が見えないアプリは安全ではなく、まだ厳しい形で試されていないだけだからだ。 + +ガバナンスされたアプリが、アイデンティティをまったく持たない呼び出し手に残す唯一の扉——公開フォームビュー——は[匿名の訪問者にフォームを送信させて、統制された業務アプリに取り込むには?](/ja/blog/how-to-let-anonymous-visitors-submit-a-form/)で実測している。権限はフォーム自身の宣言から導出され、幅はフィールド五つ、17.7.0 での結果だ。 diff --git a/content/blog/is-lovable-safe-for-production/index.ko.mdx b/content/blog/is-lovable-safe-for-production/index.ko.mdx index fc178bd..164b407 100644 --- a/content/blog/is-lovable-safe-for-production/index.ko.mdx +++ b/content/blog/is-lovable-safe-for-production/index.ko.mdx @@ -3,6 +3,7 @@ title: "Lovable는 프로덕션에 안전한가: 접근 제어 리뷰의 문제" description: "Lovable는 빠른 프로토타입에 강하다. 하지만 프로덕션 시스템에서 물어야 할 것은 작동하는지가 아니라, 누가 접근 제어를 검토할 수 있는지다. RLS, 프런트엔드 필터, 보안 스캔의 경계를 구분해야 한다." author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -84,3 +85,5 @@ Lovable의 공으로 인정하자. 이 비판은 가닿았고 그들은 응답 ObjectStack는 유출을 일으키는 그 분리를 뒤집는다. 접근 제어는 만든 이가 읽지 못하는 생성 SQL이 아니다. **선언된, 읽을 수 있는 메타데이터**다 — 객체의 속성으로서의 `read: owner == current_user` — 그리고 **런타임이 서버 측에서 강제하는 경계**다. 그래서 프런트엔드 필터를 떼어도 아무것도 바뀌지 않는다. AI가 누가-무엇을-읽을-수-있는지에 대한 변경을 제안하면, 그것은 **비전문가가 실제로 승인할 수 있는 diff**로 드러난다 ("이렇게 하면 `customers`가 모두에게 읽힙니다 — 확인할까요?"). SQL을 읽어야만 잡아낼 수 있었던, 조용한 `using (true)`로는 결코 나타나지 않는다. 그리고 **셀프 호스팅 가능**하고 당신이 이미 운영 중인 시스템 위에 얹히기 때문에, 민감한 데이터가 당신이 점검할 수 없는 어딘가로 조용히 흘러들지 않는다. 우리는 첫 데모까지의 시간에서 Lovable를 이기지 못할 것이고, 프로토타입이라면 시도조차 하지 않을 것이다. 진짜 데이터가 걸리는 순간 중요해지는 주장은 이것이다. AI가 만든 앱의 보안은 *그것에 책임지는 사람에게 보여야 한다* — 경계를 볼 수 없는 앱은 안전한 게 아니라 아직 혹독한 방식으로 시험받지 않았을 뿐이기 때문이다. + +거버넌스된 앱이 정체성이 전혀 없는 호출자에게 남겨 두는 유일한 문 — 공개 폼 뷰 — 은 [익명 방문자가 거버넌스된 업무 앱에 폼을 제출하게 하려면?](/ko/blog/how-to-let-anonymous-visitors-submit-a-form/)에서 실측했다. 권한은 폼 자체의 선언에서 도출되고, 너비는 필드 다섯 개, 17.7.0 기준이다. diff --git a/content/blog/is-lovable-safe-for-production/index.mdx b/content/blog/is-lovable-safe-for-production/index.mdx index 688b100..64a7f69 100644 --- a/content/blog/is-lovable-safe-for-production/index.mdx +++ b/content/blog/is-lovable-safe-for-production/index.mdx @@ -3,6 +3,7 @@ title: "Is Lovable Safe for Production? The Access-Control Review Problem" description: "Lovable can turn a sentence into a full-stack app, but production data depends on access control the accountable builder must be able to inspect, understand, and approve." author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -84,3 +85,5 @@ If they cannot run that test, do not understand it, or the answer is "everyone's ObjectStack inverts the split that causes the leaks. Access control is not generated SQL the builder cannot read; it is **declared, readable metadata**, such as `read: owner == current_user` as a property of the object. It is a **boundary the runtime enforces server-side**, so removing a frontend filter changes nothing. When the AI proposes a change to who-can-read-what, it surfaces as a **diff a non-expert can actually approve**: "this makes `customers` readable by everyone; confirm?" It should not appear as silent SQL someone would have to read to catch. And because ObjectStack is **self-hostable** and sits on systems you already run, sensitive data does not quietly land somewhere you cannot inspect. We will not beat Lovable on time-to-first-demo, and for prototypes we would not try. The claim is the one that matters the instant real data is involved: the security of an AI-built app has to be *visible to the person accountable for it*. An app whose boundary you cannot see is not safe; it is simply unverified. + +The one door a governed app does leave open to callers with no identity at all — a public form view — is measured in [How Do You Let Anonymous Visitors Submit a Form Into a Governed Business App?](/en/blog/how-to-let-anonymous-visitors-submit-a-form/): the grant is derived from the form's own declaration, five fields wide, on 17.7.0. diff --git a/content/blog/is-lovable-safe-for-production/index.zh-Hans.mdx b/content/blog/is-lovable-safe-for-production/index.zh-Hans.mdx index 1c86141..b7d3b28 100644 --- a/content/blog/is-lovable-safe-for-production/index.zh-Hans.mdx +++ b/content/blog/is-lovable-safe-for-production/index.zh-Hans.mdx @@ -3,6 +3,7 @@ title: "Lovable 上生产安全吗:访问控制审查问题" description: "Lovable 很适合快速原型,但生产系统的问题不是能不能跑,而是谁能审查访问控制。RLS、前端过滤和安全扫描都重要;真正的边界必须在服务端可见、可强制、可签字。" author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -84,3 +85,5 @@ create policy "Enable read access for all users" ObjectStack 把那个导致泄露的拆分反转过来。访问控制不是构建者读不懂的生成 SQL;它是**被声明出来、可读的元数据**——作为对象一个属性的 `read: owner == current_user`——而且它是一条**运行时在服务端强制执行的边界**,所以去掉一个前端过滤什么都改变不了。当 AI 提议改动「谁能读到什么」时,它会作为一份**非专家真的能批准的 diff** 浮现出来(「这会让 `customers` 对所有人可读——确认吗?」),而绝不会是一个你本得读 SQL 才能抓到的、悄无声息的 `using (true)`。而且因为它是**可自托管的**、并坐落在你本来就在运行的系统上,敏感数据不会悄悄落到某个你无法检视的地方。 我们不会在「到第一个 demo 的时间」上赢过 Lovable,而对于原型我们也不会去试。真正要紧的主张,是在真实数据一旦牵涉其中的那一刻才生效的:一个 AI 构建的应用,它的安全边界必须*对那个为它担责的人是可见的*。看不见边界,不等于边界不存在;但在生产系统里,它意味着你还没有完成可签字的审查。 + +受治理的应用向完全没有身份的调用者留下的唯一一道门——公开表单视图——在[怎样让匿名访客把表单提交进一个受治理的业务应用?](/zh-Hans/blog/how-to-let-anonymous-visitors-submit-a-form/)中实测:授权从表单自身的声明推导,五个字段宽,17.7.0 上的结果。 diff --git a/content/blog/is-lovable-safe-for-production/index.zh-Hant.mdx b/content/blog/is-lovable-safe-for-production/index.zh-Hant.mdx index 8588b45..b14cdfb 100644 --- a/content/blog/is-lovable-safe-for-production/index.zh-Hant.mdx +++ b/content/blog/is-lovable-safe-for-production/index.zh-Hant.mdx @@ -4,6 +4,7 @@ title: "Lovable 上生產安全嗎:訪問控制審查問題" description: "Lovable 很適合快速原型,但生產系統的問題不是能不能跑,而是誰能審查訪問控制。RLS、前端過濾和安全掃描都重要;真正的邊界必須在服務端可見、可強制、可簽字。" author: ObjectStack Team date: 2026-06-24 +updated: 2026-10-22 status: published topic: governance audience: developer @@ -85,3 +86,5 @@ create policy "Enable read access for all users" ObjectStack 把那個導致洩露的拆分反轉過來。訪問控制不是構建者讀不懂的生成 SQL;它是**被宣告出來、可讀的後設資料**——作為物件一個屬性的 `read: owner == current_user`——而且它是一條**執行時在服務端強制執行的邊界**,所以去掉一個前端過濾什麼都改變不了。當 AI 提議改動「誰能讀到什麼」時,它會作為一份**非專家真的能批准的 diff** 浮現出來(「這會讓 `customers` 對所有人可讀——確認嗎?」),而絕不會是一個你本得讀 SQL 才能抓到的、悄無聲息的 `using (true)`。而且因為它是**可自託管的**、並坐落在你本來就在執行的系統上,敏感資料不會悄悄落到某個你無法檢視的地方。 我們不會在「到第一個 demo 的時間」上贏過 Lovable,而對於原型我們也不會去試。真正要緊的主張,是在真實資料一旦牽涉其中的那一刻才生效的:一個 AI 構建的應用,它的安全邊界必須*對那個為它擔責的人是可見的*。看不見邊界,不等於邊界不存在;但在生產系統裡,它意味著你還沒有完成可簽字的審查。 + +受治理的應用向完全沒有身份的呼叫者留下的唯一一道門——公開表單檢視——在[怎樣讓匿名訪客把表單提交進一個受治理的業務應用?](/zh-Hant/blog/how-to-let-anonymous-visitors-submit-a-form/)中實測:授權從表單自身的宣告推導,五個欄位寬,17.7.0 上的結果。 diff --git a/content/blog/objectos-agent-permission-boundaries/index.mdx b/content/blog/objectos-agent-permission-boundaries/index.mdx index bec3dde..304a21c 100644 --- a/content/blog/objectos-agent-permission-boundaries/index.mdx +++ b/content/blog/objectos-agent-permission-boundaries/index.mdx @@ -3,6 +3,7 @@ title: "Where AI Agent Permissions Are Enforced: Row-Level Security, Field Maski description: "Everyone agrees an AI agent should respect permissions. The real question is where the check runs. Filtering the model's output is too late — enforcement belongs in the query, the field, and the tool gate." author: ObjectStack Team date: 2026-06-05T11:00:00+08:00 +updated: 2026-10-22 status: published topic: governance audience: it @@ -114,4 +115,6 @@ Every enforcement point above is declared, not coded: a predicate on a permissio It is also what makes this tractable when the code is written by an agent rather than a person. The reviewer is not reading a pull request full of query construction to confirm the tenant filter is present in all seventeen paths. They are reading a permission set. The runtime supplies the rest, and the build refuses the permissions that are made of paper. +That holds even for the one caller with no identity at all: an anonymous visitor on a public form gets a grant [derived from the form's own declaration](/en/blog/how-to-let-anonymous-visitors-submit-a-form/) — five fields wide, measured on 17.7.0. + If you are pointing a coding agent at a backend this quarter, the property to select for is not how quickly it scaffolds CRUD. It is whether the resulting permissions can be proven to enforce — by a gate, before anything ships. Point your agent at the [ObjectStack spec](https://github.com/objectstack-ai/objectstack) and review what it declares. diff --git a/content/blog/objectos-agent-permission-boundaries/index.zh-Hans.mdx b/content/blog/objectos-agent-permission-boundaries/index.zh-Hans.mdx index dd59a5f..b7a2e1e 100644 --- a/content/blog/objectos-agent-permission-boundaries/index.zh-Hans.mdx +++ b/content/blog/objectos-agent-permission-boundaries/index.zh-Hans.mdx @@ -3,6 +3,7 @@ title: AI Agent 权限检查跑在哪一层:查询下推、字段脱敏与工 description: 该不该守权限已经没有争议;决定这道边界真假的是检查跑在哪一层。数据进入模型上下文之后再过滤,等于没过滤——那是纸面权限。本文拆开查询、字段、工具闸门三个执行点,以及平台明确不兜底的两处。 author: ObjectStack Team date: 2026-06-05T11:00:00+08:00 +updated: 2026-10-22 status: published topic: governance audience: business @@ -147,4 +148,6 @@ ObjectStack 里 agent 的数据工具从不直接碰引擎。它们走的是和 当代码是 agent 写的而不是人写的时候,这一点会变得更要紧。评审的人不是在读一个满是查询拼装的 PR,去确认十七条路径里都带上了租户过滤条件。他读的是一个权限集,剩下的由运行时供给,而构建会拒掉那些纸做的权限。 +即使是完全没有身份的调用者也不例外:匿名访客通过公开表单得到的授权[从表单自身的声明推导](/zh-Hans/blog/how-to-let-anonymous-visitors-submit-a-form/)——五个字段宽,在 17.7.0 上实测。 + 如果你这个季度打算把编码 agent 指向某个后端,要挑的属性不是它搭 CRUD 有多快,而是**它产出的权限能不能在上线之前被证明会生效**。把你的 agent 指向 [ObjectStack 规范](https://github.com/objectstack-ai/objectstack),然后审它声明了什么。 diff --git a/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx b/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx index 4f8c360..48e02ce 100644 --- a/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx +++ b/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx @@ -4,6 +4,7 @@ title: AI Agent 權限檢查跑在哪一層:查詢下推、欄位脫敏與工 description: 該不該守權限已經沒有爭議;決定這道邊界真假的是檢查跑在哪一層。資料進入模型上下文之後再過濾,等於沒過濾——那是紙面權限。本文拆開查詢、欄位、工具閘門三個執行點,以及平臺明確不兜底的兩處。 author: ObjectStack Team date: 2026-06-05T11:00:00+08:00 +updated: 2026-10-22 status: published topic: governance audience: business @@ -148,4 +149,6 @@ ObjectStack 裡 agent 的資料工具從不直接碰引擎。它們走的是和 當代碼是 agent 寫的而不是人寫的時候,這一點會變得更要緊。評審的人不是在讀一個滿是查詢拼裝的 PR,去確認十七條路徑裡都帶上了租戶過濾條件。他讀的是一個權限集,剩下的由執行時供給,而構建會拒掉那些紙做的權限。 +即使是完全沒有身份的呼叫者也不例外:匿名訪客通過公開表單得到的授權[從表單自身的宣告推導](/zh-Hant/blog/how-to-let-anonymous-visitors-submit-a-form/)——五個欄位寬,在 17.7.0 上實測。 + 如果你這個季度打算把編碼 agent 指向某個後端,要挑的屬性不是它搭 CRUD 有多快,而是**它產出的權限能不能在上線之前被證明會生效**。把你的 agent 指向 [ObjectStack 規範](https://github.com/objectstack-ai/objectstack),然後審它聲明瞭什麼。