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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions cmd/keyring/cloudflare_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ const scopedClientBinding = "OAUTH_CLIENT_SECRETS_BY_INSTANCE"

// This is an initial import, not a read/modify/write of an opaque Worker secret.
// Cloudflare offers no compare-and-set here: the operator must exclude competing
// deployment/secret writers until the command finishes. An existing binding of
// any type is refused rather than risking loss of another instance's entries.
// deployment/secret writers until the command finishes. An existing destination
// binding of any type is refused rather than overwriting unknown entries.
func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("cloudflare-import-client", flag.ContinueOnError)
fs.SetOutput(io.Discard)
base := fs.String("relay-base", "", "existing loopback Keyring relay URL for the Cloudflare API")
account := fs.String("account", "", "confirmed Cloudflare account ID")
worker := fs.String("worker", "", "confirmed existing Worker name")
id := fs.String("keyring-id", "", "pilot ID confirmed from the authenticated person's canonical mapping")
shared := fs.Bool("shared", false, "initial shared-client overlay import instead of a per-instance binding")
checkOnly := fs.Bool("check-only", false, "check destination and binding absence without reading a secret or writing")
exclusive := fs.Bool("exclusive-writer", false, "operator has excluded other Worker deployment/secret writers during import")
if err := fs.Parse(args); err != nil {
Expand All @@ -47,8 +48,12 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io
fmt.Fprintln(stderr, "cloudflare-import-client: "+message)
return 1
}
if fs.NArg() != 0 || !lowerHexID(*account) || !lowerHexID(*id) || !workerName(*worker) {
return fail("pass confirmed --account, --worker and --keyring-id; no positional secret or file arguments")
if fs.NArg() != 0 || !lowerHexID(*account) || !workerName(*worker) || (*shared && *id != "") || (!*shared && !lowerHexID(*id)) {
return fail("pass confirmed --account, --worker and exactly one of --keyring-id or --shared; no positional secret or file arguments")
}
binding := scopedClientBinding
if *shared {
binding = "OAUTH_CLIENT_SECRETS_SHARED"
}
u, err := url.Parse(*base)
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || !net.ParseIP(u.Hostname()).IsLoopback() || u.Path == "" || strings.Contains(u.Path, "..") {
Expand Down Expand Up @@ -97,11 +102,11 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io
if err != nil {
return fail("could not verify existing Worker bindings; no write attempted")
}
if _, exists := before[scopedClientBinding]; exists {
return fail("target binding already exists; refuse to overwrite unknown per-instance entries")
if _, exists := before[binding]; exists {
return fail("target binding already exists; refuse to overwrite unknown entries")
}
if *checkOnly {
return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, false)
return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, binding, false)
}
// Reuse the native Linux/Darwin private-pipe and bounded-input checks. Neither
// the pipe contents nor provider response bodies are ever printed or saved.
Expand All @@ -112,15 +117,20 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io
if !utf8.ValidString(secret) || strings.TrimSpace(secret) != secret || strings.ContainsAny(secret, "\r\n\x00") {
return fail("expected an exact UTF-8 client-secret value without surrounding whitespace or line breaks")
}
value, err := json.Marshal(map[string]map[string]string{*id: {"GOOGLE_CLIENT_SECRET": secret}})
var value []byte
if *shared {
value, err = json.Marshal(map[string]string{"GOOGLE_CLIENT_SECRET": secret})
} else {
value, err = json.Marshal(map[string]map[string]string{*id: {"GOOGLE_CLIENT_SECRET": secret}})
}
if err != nil {
return fail("could not encode scoped secret")
return fail("could not encode client secret")
}
body, err := json.Marshal(struct {
Name string `json:"name"`
Type string `json:"type"`
Text string `json:"text"`
}{scopedClientBinding, "secret_text", string(value)})
}{binding, "secret_text", string(value)})
if err != nil {
return fail("could not encode binding")
}
Expand All @@ -134,18 +144,18 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io
Name string `json:"name"`
Type string `json:"type"`
}
if err := cloudflareImportRequest(client, http.MethodPut, root+"/secrets", body, &accepted); err != nil || accepted.Name != scopedClientBinding || accepted.Type != "secret_text" {
if err := cloudflareImportRequest(client, http.MethodPut, root+"/secrets", body, &accepted); err != nil || accepted.Name != binding || accepted.Type != "secret_text" {
return fail("submission was not confirmed; binding state may have changed; do not retry blindly")
}
after, err := cloudflareBindings(client, root)
if err != nil || after[scopedClientBinding] != "secret_text" {
if err != nil || after[binding] != "secret_text" {
return fail("write acknowledged but binding metadata verification failed; do not retry blindly")
}
delete(after, scopedClientBinding)
delete(after, binding)
if !sameBindings(before, after) {
return fail("write acknowledged but unrelated binding metadata changed; stop and reconcile with the deployment owner")
}
return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, true)
return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, binding, true)
}

func lowerHexID(s string) bool {
Expand Down Expand Up @@ -243,17 +253,17 @@ func cloudflareImportRequest(client *http.Client, method, target string, body []
return nil
}

func cloudflareImportReceipt(stdout, stderr io.Writer, account, worker, id string, written bool) int {
func cloudflareImportReceipt(stdout, stderr io.Writer, account, worker, id, binding string, written bool) int {
receipt := struct {
Account string `json:"account"`
Worker string `json:"worker"`
KeyringID string `json:"keyring_id"`
KeyringID string `json:"keyring_id,omitempty"`
Binding string `json:"binding"`
Ready bool `json:"ready"`
Written bool `json:"write_acknowledged"`
ValueReadback bool `json:"value_readback"`
RuntimeChecked bool `json:"runtime_import_verified"`
}{account, worker, id, scopedClientBinding, !written, written, false, false}
}{account, worker, id, binding, !written, written, false, false}
if json.NewEncoder(stdout).Encode(receipt) != nil {
fmt.Fprintln(stderr, "cloudflare-import-client: receipt output failed; inspect binding metadata before retrying")
return 1
Expand Down
82 changes: 82 additions & 0 deletions cmd/keyring/cloudflare_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,85 @@ func TestCloudflareClientImport(t *testing.T) {
})
}
}

func TestCloudflareSharedClientImport(t *testing.T) {
const binding = "OAUTH_CLIENT_SECRETS_SHARED"
const secret = "synthetic-new-shared-secret"
const global = `{"EXISTING_CLIENT":"synthetic-global"}`
const pilot = `{"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb":{"GOOGLE_CLIENT_SECRET":"synthetic-pilot"}}`
for _, mode := range []string{"import", "existing", "ambiguous-scope"} {
t.Run(mode, func(t *testing.T) {
var mu sync.Mutex
values := map[string]string{"OAUTH_CLIENT_SECRETS": global, scopedClientBinding: pilot}
if mode == "existing" {
values[binding] = `{"OTHER":"already-installed"}`
}
writes := 0
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/settings"):
bindings := make([]map[string]string, 0, len(values))
for name := range values {
bindings = append(bindings, map[string]string{"name": name, "type": "secret_text"})
}
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"bindings": bindings}})
case r.Method == "PUT" && strings.HasSuffix(r.URL.Path, "/secrets"):
writes++
var input struct{ Name, Type, Text string }
if json.NewDecoder(r.Body).Decode(&input) != nil || input.Name != binding || input.Type != "secret_text" {
w.WriteHeader(http.StatusBadRequest)
return
}
values[input.Name] = input.Text
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"name": input.Name, "type": input.Type}})
default:
http.NotFound(w, r)
}
}))
defer api.Close()
args := []string{"cloudflare-import-client", "--relay-base", api.URL + "/operator/cloudflare/client/v4", "--account", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "--worker", "keyring-instances", "--shared", "--exclusive-writer"}
if mode == "ambiguous-scope" {
args = append(args, "--keyring-id", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
}
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
if _, err := io.WriteString(writer, secret); err != nil {
writer.Close()
t.Fatal(err)
}
writer.Close()
var out, diagnostics bytes.Buffer
code := run(args, reader, &out, &diagnostics)
mu.Lock()
defer mu.Unlock()
if values["OAUTH_CLIENT_SECRETS"] != global || values[scopedClientBinding] != pilot {
t.Fatal("existing global or pilot secret map changed")
}
if strings.Contains(out.String()+diagnostics.String(), secret) {
t.Fatal("shared secret appeared in output")
}
if mode == "import" {
var imported map[string]string
if code != 0 || writes != 1 || json.Unmarshal([]byte(values[binding]), &imported) != nil || len(imported) != 1 || imported["GOOGLE_CLIENT_SECRET"] != secret {
t.Fatal("shared secret did not reach the independent overlay")
}
var receipt struct {
Binding string `json:"binding"`
KeyringID string `json:"keyring_id"`
Written bool `json:"write_acknowledged"`
}
if json.Unmarshal(out.Bytes(), &receipt) != nil || receipt.Binding != binding || receipt.KeyringID != "" || !receipt.Written {
t.Fatal("shared import receipt misidentifies its scope")
}
} else if code == 0 || writes != 0 || (mode == "existing" && values[binding] != `{"OTHER":"already-installed"}`) {
t.Fatal("unsafe shared import changed protected settings")
}
})
}
}
46 changes: 45 additions & 1 deletion deploy/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ Inputs:
| `CALLER_SECRET` | secret | the router's `X-Keyring-Caller` value, one per Phobos server |
| `ADMIN_TOKEN` | secret | the router's admin bearer token |
| `OAUTH_CLIENT_SECRETS` | secret | JSON `{"GOOGLE_CLIENT_SECRET": "..."}`, pushed into every keyring |
| `OAUTH_CLIENT_SECRETS_BY_INSTANCE` | secret | JSON `{"keyring_id": {"GOOGLE_CLIENT_SECRET": "..."}}`, imported only by the named keyring; overrides matching global names there |
| `OAUTH_CLIENT_SECRETS_SHARED` | secret | Optional additive JSON map, pushed into every keyring; overrides matching global names without replacing the existing opaque global binding |
| `OAUTH_CLIENT_SECRETS_BY_INSTANCE` | secret | JSON `{"keyring_id": {"GOOGLE_CLIENT_SECRET": "..."}}`, imported only by the named keyring; overrides matching global and shared names there |

Gmail and Calendar need an owner-approved Web application OAuth client with redirect URI `<PUBLIC_BASE>/connect/callback`; put the same client ID in both service entries. For a personal pilot, update only that person's existing instance, not the global template. The separate Drive connection below uses Google's own `gcloud` client and needs no Phobos-owned OAuth application. Do not publish a Google project, create shared credentials, or deploy the sample Workspace grants without the owner's approval.

Expand Down Expand Up @@ -247,6 +248,49 @@ Compatible Worker/container deployment, instance startup/import and real-account
acceptance are separate gates. No public receiver or additional permission is
introduced by this command.

### Promoting a pilot client without replacing opaque bindings

The merge order is global, shared, then per-instance. Use
`OAUTH_CLIENT_SECRETS_SHARED` when the existing global binding's contents cannot
be recovered safely. Do not overwrite an unknown global map or remove the pilot
override. An absent shared binding leaves existing behavior unchanged.

With owner approval for the shared credential scope, the same importer accepts
`--shared` **instead of** `--keyring-id`. Preflight:

```sh
keyring cloudflare-import-client \
--relay-base "$CLOUDFLARE_RELAY_BASE" \
--account "$CLOUDFLARE_ACCOUNT_ID" \
--worker "$KEYRING_WORKER" \
--shared --check-only
```

The authorized producer then invokes that command with `--exclusive-writer`
instead of `--check-only`, passing the exact secret through an anonymous pipe.
It creates only `{"GOOGLE_CLIENT_SECRET":"..."}` in the shared binding and refuses
any existing destination. The old global and per-instance bindings are untouched;
the same input, secrecy, no-retry and exclusive-writer requirements apply.
There is no arbitrary binding-name option.

For a new shared Workspace rollout, keep both services' OAuth client, scopes,
refresh key and router access consistent with the approved pilot. Changing
`SERVICES_TEMPLATE` affects new instances only: repair approved existing instances
with the documented per-service PUT, preserving their other grants. Do not repair
or sign in the pilot as a rollout check. Confirm container rollout readiness and
non-pilot consent-link creation before handing off the real Gmail/Calendar check.

If the original secret is unavailable, provisioning an additional active secret
on the same Google client requires its own applicable owner authorization; never
reset or revoke the pilot's secret as a shortcut. Importing the shared binding
changes future imports into all instances, not just the repaired instances.
Per-instance values still win, so a pilot retaining its override keeps that secret.
Rollback must preserve both original bindings and the pilot configuration.
Removing a binding does **not** erase a value already imported into a keyring;
reverting the Worker is not credential revocation. Coordinate rollback with the
deployment owner rather than deleting secrets or restoring whole old settings.



### Pilot authorization is not public-launch approval

Expand Down
44 changes: 37 additions & 7 deletions deploy/cloudflare/smoke/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ const env = {
MASTER_KEY: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64"),
CALLER_SECRET: "caller-secret",
ADMIN_TOKEN: "admin-token",
OAUTH_CLIENT_SECRETS: JSON.stringify({ GOOGLE_CLIENT_SECRET: "client-secret-value" }),
OAUTH_CLIENT_SECRETS: JSON.stringify({ GOOGLE_CLIENT_SECRET: "client-secret-value", OTHER_CLIENT_SECRET: "client-secret-value" }),
OAUTH_CLIENT_SECRETS_SHARED: "",
OAUTH_CLIENT_SECRETS_BY_INSTANCE: "{}",
// Passed as a string when SMOKE_TEMPLATE_AS_STRING is set, as `wrangler deploy --var` does.
SERVICES_TEMPLATE: ((t: object) => (process.env.SMOKE_TEMPLATE_AS_STRING ? JSON.stringify(t) : t))({
Expand Down Expand Up @@ -427,11 +428,12 @@ try {
port: 0,
async fetch(req) {
const path = new URL(req.url).pathname;
if (req.method !== "POST" || !["/pilot/token", "/global/token"].includes(path)) return new Response("", { status: 404 });
if (req.method !== "POST" || !["/pilot/token", "/global/token", "/shared/token"].includes(path)) return new Response("", { status: 404 });
const form = await req.formData();
const pilot = path === "/pilot/token";
if (form.get("client_id") !== (pilot ? "pilot-client" : "global-client") ||
form.get("client_secret") !== (pilot ? "synthetic-owner-only-secret" : "client-secret-value")) {
const mode = path.split("/")[1];
const expectedSecret = mode === "pilot" ? "synthetic-owner-only-secret" : mode === "shared" ? "synthetic-shared-secret" : "client-secret-value";
if (form.get("client_id") !== `${mode}-client` ||
form.get("client_secret") !== expectedSecret) {
return Response.json({ error: "invalid_client" }, { status: 400 });
}
return Response.json({ refresh_token: "synthetic-refresh", access_token: "synthetic-access", token_type: "Bearer", expires_in: 3600 });
Expand All @@ -445,13 +447,13 @@ try {
await containers.get(person.keyring_id)!.destroy();
objects.delete(person.keyring_id);
}
const configure = async (id: string, mode: "pilot" | "global") => {
const configure = async (id: string, mode: "pilot" | "global" | "shared") => {
const response = await call("PUT", `/admin/instances/${id}/services/gmail`, ADMIN, JSON.stringify({
service: {
base: `http://127.0.0.1:${api.port}`, key: "GMAIL_REFRESH", auth: "oauth2",
oauth2: {
token_url: `http://127.0.0.1:${provider.port}/${mode}/token`,
client_id: `${mode}-client`, client_secret_key: "GOOGLE_CLIENT_SECRET",
client_id: `${mode}-client`, client_secret_key: mode === "global" ? "OTHER_CLIENT_SECRET" : "GOOGLE_CLIENT_SECRET",
auth_url: "https://accounts.example.com/auth", scopes: ["read"],
},
},
Expand Down Expand Up @@ -482,6 +484,33 @@ try {
await configure(other.keyring_id, "pilot");
expect("unselected keyring cannot consent with the pilot client", (await connect(other.keyring_id)).status, 502);
expect("unselected keyring cannot reuse its old grant as the pilot", (await read(other)).status, 503);
// Add a shared client without reading/replacing either existing opaque map.
env.OAUTH_CLIENT_SECRETS_SHARED = JSON.stringify({ GOOGLE_CLIENT_SECRET: "synthetic-shared-secret" });
for (const person of [pilot, other]) {
await containers.get(person.keyring_id)!.destroy();
objects.delete(person.keyring_id);
}
await configure(other.keyring_id, "shared");
expect("shared overlay enables an existing non-pilot keyring", (await connect(other.keyring_id)).status, 200);
expect("shared overlay grant can reach its upstream", (await read(other)).status, 200);
expect("pilot override still wins over the shared client", (await read(pilot)).status, 200);
await configure(other.keyring_id, "global");
expect("unrelated global client survives shared overlay", (await connect(other.keyring_id)).status, 200);
const fresh = await (await call("POST", "/admin/instances", ADMIN, JSON.stringify({ label: "shared-fresh" }))).json() as typeof pilot;
await configure(fresh.keyring_id, "shared");
expect("new keyring receives shared client", (await connect(fresh.keyring_id)).status, 200);
await containers.get(fresh.keyring_id)!.destroy();
objects.delete(fresh.keyring_id);
expect("shared grant survives sealed cold restore", (await read(fresh)).status, 200);
env.OAUTH_CLIENT_SECRETS_SHARED = "synthetic-shared-secret-not-json";
await containers.get(fresh.keyring_id)!.destroy();
objects.delete(fresh.keyring_id);
const sharedFailure = await call("GET", `/admin/instances/${fresh.keyring_id}/services`, ADMIN);
expect("malformed shared settings fail without leaking input",
[sharedFailure.status, (await sharedFailure.text()).includes("synthetic-shared-secret")], [502, false]);
env.OAUTH_CLIENT_SECRETS_SHARED = JSON.stringify({ GOOGLE_CLIENT_SECRET: "synthetic-shared-secret" });
objects.delete(fresh.keyring_id);
await call("DELETE", `/admin/instances/${fresh.keyring_id}`, ADMIN);
const savedSettings = env.OAUTH_CLIENT_SECRETS_BY_INSTANCE;
env.OAUTH_CLIENT_SECRETS_BY_INSTANCE = "synthetic-private-secret-not-json";
await containers.get(pilot.keyring_id)!.destroy();
Expand All @@ -496,6 +525,7 @@ try {
} finally {
env.OAUTH_CLIENT_SECRETS_BY_INSTANCE = "{}";
provider.stop(true);
env.OAUTH_CLIENT_SECRETS_SHARED = "";
}
}

Expand Down
Loading
Loading