Skip to content
Open
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
19 changes: 19 additions & 0 deletions .changeset/read-only-driver-auth-post.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions go/internal/driverrepo/sourceful.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions go/internal/drivers/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions go/internal/drivers/control_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions go/internal/drivers/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"math"
net_url "net/url"
"sync"
"time"

Expand Down Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
92 changes: 92 additions & 0 deletions go/internal/drivers/read_only_auth_post_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}