diff --git a/.changeset/read-only-driver-auth-post.md b/.changeset/read-only-driver-auth-post.md new file mode 100644 index 00000000..3bb11e60 --- /dev/null +++ b/.changeset/read-only-driver-auth-post.md @@ -0,0 +1,19 @@ +--- +"ftw": minor +--- + +A read-only driver may now sign in before it reads. + +A driver that reads a vendor cloud cannot read anything until it has exchanged +a token, and it exchanges one with a POST issued from `driver_init` or +`driver_poll` — the phases the write scope refuses, correctly, since nothing +there carries a command lease. Such a driver could therefore only be published +control-capable, and its catalog entry then claimed a control path it does not +have. + +`RuntimePolicy` now carries `auth_post_path` from the signed driver manifest, +and `host.http_post` skips the write scope only for a URL whose path equals it. +The exemption requires a read-only policy, a declared path, `http.post` still +granted, and an exact path match, and it does not consume the write budget — a +token refresh is driven by expiry rather than by a caller. A driver that +declares nothing behaves exactly as before. diff --git a/go/internal/driverrepo/sourceful.go b/go/internal/driverrepo/sourceful.go index 57d95c9e..adfd07a2 100644 --- a/go/internal/driverrepo/sourceful.go +++ b/go/internal/driverrepo/sourceful.go @@ -758,6 +758,10 @@ func (m *Manager) directManifestRuntimePolicy( HostAPIProfile: sourcefulFTWHostAPIProfileV1, ReadOnly: true, Permissions: permissions, + // Only a read-only driver can have one, and only the path the signed + // manifest names. An unsigned or absent value leaves it empty, which + // is the same as having no exemption at all. + AuthPostPath: matched.Metadata.AuthPostPath, }, nil } diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 7e7d09bb..1a9c4168 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -38,6 +38,10 @@ type CatalogEntry struct { Description string `json:"description,omitempty"` Homepage string `json:"homepage,omitempty"` ConnectionDefaults map[string]any `json:"connection_defaults,omitempty"` + // AuthPostPath is the URL path a read-only driver signs in at. A driver + // that reads a vendor cloud has to POST for a token before it can read, + // and that POST is not actuation. Only meaningful with ReadOnly. + AuthPostPath string `json:"auth_post_path,omitempty"` // ReadOnly means the driver never accepts dispatch commands. The catalog // UI uses it to avoid presenting battery capacity as a control opt-in and // to enable battery_telemetry_only for gateway-style drivers. diff --git a/go/internal/drivers/control_v2.go b/go/internal/drivers/control_v2.go index c31d7fbb..ad7724c8 100644 --- a/go/internal/drivers/control_v2.go +++ b/go/internal/drivers/control_v2.go @@ -36,6 +36,12 @@ type RuntimePolicy struct { Lease RuntimeLeasePolicy SiteEnabled bool MaxWrites int + // AuthPostPath is the one URL path a read-only driver may POST to. Some + // read-only drivers read a vendor cloud and cannot read anything until + // they have exchanged a token, and a token exchange is a POST issued from + // init or poll -- the phases allowWrite refuses. Empty for every driver + // that does not declare one, which is all of them by default. + AuthPostPath string } type RuntimeCommand struct { diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 2cf80f08..74e79e8e 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "math" + net_url "net/url" "sync" "time" @@ -259,6 +260,33 @@ func (h *HostEnv) endWriteScope() (int, []string) { return writes, evidence } +// allowAuthPost reports whether this POST is the sign-in the signed package +// declared, and so may proceed outside the write phases. +// +// A read-only driver that reads a vendor cloud cannot read anything until it +// has exchanged a token, and it exchanges one from init or poll -- the phases +// allowWrite refuses, for good reason, since nothing there can carry a command +// lease. Authenticating is a precondition for reading rather than a write to +// the device, so it is admitted here instead, confined to the single path the +// signed manifest names and only for a driver published read-only. It does not +// consume the write budget: a token refresh is driven by expiry, not by a +// caller, and spending the budget on it would leave the driver unable to read +// once its token aged out. +func (h *HostEnv) allowAuthPost(rawURL string) bool { + if h.RuntimePolicy == nil || !h.RuntimePolicy.ReadOnly { + return false + } + declared := h.RuntimePolicy.AuthPostPath + if declared == "" || !h.RuntimePolicy.allows("http.post") { + return false + } + parsed, err := net_url.Parse(rawURL) + if err != nil { + return false + } + return parsed.Path == declared +} + func (h *HostEnv) allowWrite(permission string) error { if h.RuntimePolicy == nil { return nil diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index bb62185c..fe9e7a9f 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -1264,10 +1264,15 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString("http: capability not granted")) return 2 } - if err := env.allowWrite("http.post"); err != nil { - L.Push(lua.LNil) - L.Push(lua.LString(err.Error())) - return 2 + // A read-only driver signing in at the path its signed manifest + // declares is reading, not writing, so it skips the write phase and + // budget. Every other POST goes through allowWrite unchanged. + if !env.allowAuthPost(L.CheckString(1)) { + if err := env.allowWrite("http.post"); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } } url := L.CheckString(1) if ok, reason := hostAllowed(url); !ok { diff --git a/go/internal/drivers/read_only_auth_post_test.go b/go/internal/drivers/read_only_auth_post_test.go new file mode 100644 index 00000000..9f0e4dfa --- /dev/null +++ b/go/internal/drivers/read_only_auth_post_test.go @@ -0,0 +1,92 @@ +package drivers + +import ( + "strings" + "testing" +) + +// A read-only driver that reads a vendor cloud cannot read anything until it +// has exchanged a token, and it exchanges one from init or poll. Those are the +// phases allowWrite refuses, so before this the choice was to publish such a +// driver control-capable -- which is what myuplink did, and why its catalog +// entry claimed a control path its driver_command has always refused. + +func readOnlyAuthPostPolicy(path string) *RuntimePolicy { + return &RuntimePolicy{ + PackageID: "com.sourceful.driver.myuplink", + Version: "1.2.0", + ArtifactSHA256: strings.Repeat("a", 64), + ReadOnly: true, + Permissions: map[string]bool{"http.get": true, "http.post": true}, + AuthPostPath: path, + } +} + +func TestReadOnlyDriverMaySignInOutsideAWriteScope(t *testing.T) { + env := &HostEnv{RuntimePolicy: readOnlyAuthPostPolicy("/oauth/token")} + + // No write scope is open -- this is what init and poll look like. + if err := env.allowWrite("http.post"); err == nil { + t.Fatal("allowWrite should still refuse a POST outside a write scope") + } + if !env.allowAuthPost("https://api.myuplink.com/oauth/token") { + t.Fatal("the declared sign-in must be allowed from init or poll") + } + // A token refresh is driven by expiry, not by a caller, so it must not + // spend the write budget that a real command depends on. + if env.writeAttempts != 0 { + t.Fatalf("sign-in consumed the write budget: %d", env.writeAttempts) + } +} + +func TestSignInExemptionIsConfinedToTheDeclaredPath(t *testing.T) { + env := &HostEnv{RuntimePolicy: readOnlyAuthPostPolicy("/oauth/token")} + + for _, url := range []string{ + "https://api.myuplink.com/v2/devices/1/points", // the write it must not do + "https://api.myuplink.com/oauth/token/../device", // no path games + "https://api.myuplink.com/oauth", + "https://evil.example/oauth/token/extra", + "not a url at all", + } { + if env.allowAuthPost(url) { + t.Errorf("POST to %q must not pass as authentication", url) + } + } + + // A query string is part of the request, not the path. + if !env.allowAuthPost("https://api.myuplink.com/oauth/token?x=1") { + t.Error("a query string must not defeat the declared path") + } +} + +func TestSignInExemptionRequiresBeingDeclared(t *testing.T) { + // Undeclared: the ordinary write rules apply, which is every driver today. + env := &HostEnv{RuntimePolicy: readOnlyAuthPostPolicy("")} + if env.allowAuthPost("https://api.myuplink.com/oauth/token") { + t.Error("a driver that declared nothing must not be exempt") + } + + // Declared but not read-only: a controlling driver has no exemption to + // take, and must not gain a POST path that skips the write scope. + control := readOnlyAuthPostPolicy("/oauth/token") + control.ReadOnly = false + env = &HostEnv{RuntimePolicy: control} + if env.allowAuthPost("https://api.myuplink.com/oauth/token") { + t.Error("a controlling driver must not use the read-only exemption") + } + + // Declared, read-only, but the permission was never granted. + missing := readOnlyAuthPostPolicy("/oauth/token") + missing.Permissions = map[string]bool{"http.get": true} + env = &HostEnv{RuntimePolicy: missing} + if env.allowAuthPost("https://api.myuplink.com/oauth/token") { + t.Error("the exemption must still require http.post to be granted") + } + + // An unmanaged driver has no policy and is unaffected either way. + env = &HostEnv{} + if env.allowAuthPost("https://api.myuplink.com/oauth/token") { + t.Error("a driver with no runtime policy must not report an exemption") + } +}