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
48 changes: 44 additions & 4 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any
}
trust += ": operators " + strings.Join(ids, ", ")
}
if f.Trust.AllowAssignments {
trust += "; they may assign"
}
d := map[string]any{
"file": exact(path),
"account": f.AccountID,
Expand Down Expand Up @@ -260,6 +263,9 @@ type connectSetupFlags struct {

trust string
allow []string
// allowAssignments is --allow-assignments-from-authorized, unset when
// not passed so setup keeps what connect.json has.
allowAssignments optionalBool

serve []string
classes []string
Expand Down Expand Up @@ -308,9 +314,14 @@ Trust. operator (default): the operator alone. allowlist: the operator and
the people passed with --allow. project: any non-client member of the event's
project, as a participant, beside the operator and any people passed with
--allow, now or in an earlier run. The operator and the people passed with
--allow are operators; a project member is a participant. Each request the connector
prints names its role.
Assignments are the operator's alone in every mode.
--allow are operators; a project member is a participant. Each request the
connector prints names its role. Assignments are the operator's alone in every
mode, unless --allow-assignments-from-authorized opts in the people the
allowlist names: the assigner is the account feed's performer, which
Basecamp writes, so naming someone trusts that person and nothing that claims
to be them. It rides with --allow: a run that passes --allow turns it off
unless it passes the flag again, and a run that passes neither keeps both.
--allow-assignments-from-authorized=false takes it back.
Comment thread
jeremy marked this conversation as resolved.
Projects. connect.json is the local list of Basecamp projects this agent
serves: --serve <project-id>, --unserve <project-id>. Nothing in a project it
Expand Down Expand Up @@ -374,6 +385,8 @@ Examples:
fl.StringVar(&f.operatorProfile, "operator-profile", "", "Profile whose identity is the operator")
fl.StringVar(&f.trust, "trust", "", "Who may drive the agent: operator, allowlist or project")
fl.StringArrayVar(&f.allow, "allow", nil, "Person id to trust as an operator besides the operator (repeatable; with no --trust it implies allowlist; works with --trust allowlist or project, not operator)")
fl.Var(&f.allowAssignments, "allow-assignments-from-authorized", "Let the people the allowlist names, this run's --allow or the list kept, assign the agent work too (default: the operator's alone; =false to turn off)")
fl.Lookup("allow-assignments-from-authorized").NoOptDefVal = "true"
fl.StringArrayVar(&f.serve, "serve", nil, "Serve a Basecamp project: <project-id> (repeatable)")
fl.StringArrayVar(&f.unserve, "unserve", nil, "Stop serving a project (repeatable)")
fl.StringArrayVar(&f.classes, "class", nil, "Classify a served project: <project-id>=<class>, or <project-id>= to clear it (repeatable)")
Expand Down Expand Up @@ -590,7 +603,9 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags)
if kind == setup.KindBotUser {
next.Agent.IdentityID = expect
}
next.Trust.OperatorID = trust.Operator.ID
if err := setup.SetOperator(&next.Trust, trust.Operator.ID, f.allowAssignments.set && f.allowAssignments.value); err != nil {
return output.ErrUsage("connect.json was not written: " + err.Error())
}
if err := next.Validate(); err != nil {
return output.ErrUsage("connect.json was not written: " + err.Error())
}
Expand Down Expand Up @@ -786,6 +801,10 @@ func canonicalAccount(raw string) (string, error) {
// changes turns setup's flags into the changes connect.json takes.
func (f *connectSetupFlags) changes() (setup.Changes, error) {
var ch setup.Changes
if f.allowAssignments.set {
on := f.allowAssignments.value
ch.AllowAssignments = &on
}
if f.trust != "" {
ch.Trust = admission.TrustMode(f.trust)
switch ch.Trust {
Expand Down Expand Up @@ -1263,3 +1282,24 @@ func connectSDKOptions() []basecamp.ClientOption {
basecamp.WithUserAgent(version.UserAgent() + " " + basecamp.DefaultUserAgent),
}
}

// optionalBool is a boolean flag that knows whether it was given, so a setup
// run that leaves it out keeps what connect.json has.
type optionalBool struct {
set, value bool
}

func (b *optionalBool) String() string { return strconv.FormatBool(b.value) }
func (b *optionalBool) Type() string { return "bool" }
func (b *optionalBool) IsBoolFlag() bool {
return true
}

func (b *optionalBool) Set(s string) error {
v, err := strconv.ParseBool(s)
if err != nil {
return err
}
b.set, b.value = true, v
return nil
}
1 change: 1 addition & 0 deletions internal/commands/connect_setup_guided.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const connectAgentProfileName = "agent"
var connectSetupPolicyFlags = []string{
"expect-identity", "operator", "operator-profile", "trust", "allow",
"serve", "unserve", "class", "watch-completions", "no-watch-completions",
"allow-assignments-from-authorized",
}

// connectGOOS is the platform guided setup checks before connecting. A
Expand Down
27 changes: 27 additions & 0 deletions internal/commands/connect_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,33 @@ func TestConnectSetupNamesOperatorsAlongsideProjectTrust(t *testing.T) {
assert.Equal(t, []int64{setupOperatorPerson + 1}, f.Trust.AllowlistIDs)
}

// --allow-assignments-from-authorized opts the named people in to assigning
// the agent work; =false takes it back, and leaving it out keeps it.
func TestConnectSetupOptsNamedOperatorsInToAssignments(t *testing.T) {
s := startConnectSetupServer(t)
connectSetupApp(t, s, "agent")
storeConnectProfile(t, s, "me", setupOperatorToken)
run := func(args ...string) *setup.File {
t.Helper()
out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), append([]string{"--operator-profile", "me", serveArg()}, args...)...)
require.NoError(t, err, out)
f, err := setup.Load(connectSetupPath(t, "agent"))
require.NoError(t, err)
return &f
}

assert.True(t, run("--allow", fmt.Sprint(setupOperatorPerson+1), "--allow-assignments-from-authorized").Trust.AllowAssignments)
assert.True(t, run().Trust.AllowAssignments, "kept when not passed")
assert.False(t, run("--allow-assignments-from-authorized=false").Trust.AllowAssignments)

out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--trust", "operator", "--allow-assignments-from-authorized")
require.Error(t, err, out, "an opt-in with nobody named is refused")
Comment thread
jeremy marked this conversation as resolved.
f, err := setup.Load(connectSetupPath(t, "agent"))
require.NoError(t, err)
assert.False(t, f.Trust.AllowAssignments, "and nothing was written")
assert.Equal(t, []int64{setupOperatorPerson + 1}, f.Trust.AllowlistIDs)
}

// The scope that decides readiness is the one the credential was granted,
// not the one the profile's configuration names.
func TestConnectSetupReadsTheGrantedScopeNotTheProfiles(t *testing.T) {
Expand Down
88 changes: 88 additions & 0 deletions internal/connector/admission/assignments_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package admission

import (
"testing"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// allow_assignments lets the people the allowlist names assign the agent work,
// as the operator does. The assigner is the feed's performer, which Basecamp
// writes and no payload carries, so naming someone here trusts them, not text
// that claims to be them. Nobody it doesn't name gains anything.
func TestNamedOperatorsMayAssignWhenOptedIn(t *testing.T) {
optedIn := func() Policy {
p := basePolicy()
p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}, AllowAssignments: true}
return p
}
assignment := func(performer int64) Event {
return Event{ID: eventID, EventType: "card.assignment_changed", BucketID: servedProj, RecordingID: recordingID, CreatorID: performer}
}
card := func(f *fakeReads) {
f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", strangerID, "<div>a card</div>")
f.summaries[recordingID].Assignees = []basecamp.Person{{ID: agentID}}
f.assignments[eventID] = []int64{agentID}
}

t.Run("a named operator assigning the agent is admitted", func(t *testing.T) {
f := newFakeReads()
card(f)
v := decide(t, newAdmitter(t, optedIn(), f), assignment(allowedID))
require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason)
assert.Equal(t, TriggerAssigned, v.Trigger)
assert.Equal(t, allowedID, v.RequesterID)
assert.Equal(t, 1, f.assignCalls, "and only once the events show this assignment added the agent")
})

t.Run("so is one named beside project trust, and the members still are not", func(t *testing.T) {
p := optedIn()
p.Trust.Mode = TrustProject
f := newFakeReads()
card(f)
f.members[servedProj] = map[int64]bool{memberID: true}
v := decide(t, newAdmitter(t, p, f), assignment(allowedID))
require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason)
assert.Equal(t, RoleOperator, v.Role)

f = newFakeReads()
card(f)
f.members[servedProj] = map[int64]bool{memberID: true}
v = decide(t, newAdmitter(t, p, f), assignment(memberID))
assert.Equal(t, ReasonAssignmentNotOperator, v.Reason)
})

t.Run("anyone else is still refused at the gate", func(t *testing.T) {
for _, performer := range []int64{strangerID, memberID} {
f := newFakeReads()
card(f)
v := decide(t, newAdmitter(t, optedIn(), f), assignment(performer))
assert.Equal(t, StateDiscarded, v.State)
assert.Equal(t, ReasonAssignmentNotOperator, v.Reason)
assert.Zero(t, f.totalReads())
}
})

t.Run("without the opt-in, a named operator's assignment is refused", func(t *testing.T) {
f := newFakeReads()
card(f)
p := optedIn()
p.Trust.AllowAssignments = false
v := decide(t, newAdmitter(t, p, f), assignment(allowedID))
assert.Equal(t, ReasonAssignmentNotOperator, v.Reason)
})

t.Run("an opt-in that names nobody is refused", func(t *testing.T) {
p := basePolicy()
p.Trust.AllowAssignments = true
assert.Error(t, p.Validate())
})

t.Run("and so is one that names only the operator, who needs none", func(t *testing.T) {
p := optedIn()
p.Trust.AllowlistIDs = []int64{operatorID}
assert.Error(t, p.Validate())
})
}
7 changes: 5 additions & 2 deletions internal/connector/admission/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
// actor type, and an event performed on someone's behalf by any agent are all
// discarded before anything else is considered, so an agent mentioning an
// agent stops at zero hops. Assignments are operator-only in every trust
// mode. For the triggers that carry an instruction in content (mentioned,
// mode, unless allow_assignments opts in the people the allowlist names; the
// assigner is the feed's performer, which Basecamp writes, so naming someone
// trusts that person and not a payload that claims to be them. For the triggers that carry an instruction in content (mentioned,
// subscribed) the recording's author must be trusted too, because the
// performer of a *.created event is not always the person who wrote it — a
// to-do moved in from another project is created by the mover.
Expand Down Expand Up @@ -62,7 +64,8 @@
// write (a completion). Allowlisting an agent is an operator error that
// Validate cannot detect; the feed's actor_types=person filter excludes
// agents at source.
// 2. An assignment is admitted only when the operator performed it, the
// 2. An assignment is admitted only when the operator performed it (or a
// person the allowlist names, under allow_assignments), the
// recording's events show this event added the agent, and the agent is
// still assigned. Missing, unfound or partial assignment data blocks as
// delta_unverified; it never admits and never discards.
Expand Down
3 changes: 3 additions & 0 deletions internal/connector/admission/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func Gate(ev Event, p Policy, m Matrix) GateResult {
needsMembership := false
switch {
case isOperator:
case rule.OperatorOnly && p.Trust.AllowAssignments && role == RoleOperator:
// A named operator the operator opted in to assigning. The
// performer is the feed's, written by Basecamp, never a payload's.
case rule.OperatorOnly:
drop(ReasonAssignmentNotOperator)
continue
Expand Down
11 changes: 7 additions & 4 deletions internal/connector/admission/matrix.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ const (
type Rule struct {
Trigger Trigger
// OperatorOnly restricts the rule to events the operator performed,
// whatever the trust mode. Assignments run the agent against a recording
// on the assigner's say-so, so no broadened mode extends to them.
// whatever the trust mode, and to the people the allowlist names when
// Trust.AllowAssignments opts them in. Assignments run the agent against
// a recording on the assigner's say-so, so no broadened mode extends to
// them by itself.
OperatorOnly bool
// RequiresServed discards the rule at the gate when connect.json does not
// serve the project. Only mentioned and assigned are answered in an
Expand Down Expand Up @@ -99,8 +101,9 @@ const (
ReasonOutOfScope Reason = "out_of_scope"
// ReasonUntrustedPerformer: the performer is not in the trust set.
ReasonUntrustedPerformer Reason = "untrusted_performer"
// ReasonAssignmentNotOperator: an assignment performed by anyone but the
// operator.
// ReasonAssignmentNotOperator: an assignment performed by anyone other
// than the operator or a person the allowlist names whom
// allow_assignments opts in.
ReasonAssignmentNotOperator Reason = "assignment_not_operator"
// ReasonUntrustedAuthor: the recording carrying the instruction was
// written by someone outside the trust set.
Expand Down
7 changes: 7 additions & 0 deletions internal/connector/admission/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ type Trust struct {
// AllowlistIDs are the Person ids trusted as operators besides the
// operator, in allowlist or project mode.
AllowlistIDs []int64 `json:"allowlist_ids,omitempty"`
// AllowAssignments lets the people AllowlistIDs names assign the agent
// work, as the operator does. Off, assignments are the operator's alone.
AllowAssignments bool `json:"allow_assignments,omitempty"`
}

// roleOf is the role a trusted person holds, before any membership read: an
Expand Down Expand Up @@ -306,6 +309,10 @@ func (p Policy) Validate() error {
return errors.New("admission: policy needs the operator's Person id")
case p.Trust.OperatorID == p.AgentID:
return errors.New("admission: the operator cannot be the agent itself")
case p.Trust.AllowAssignments && !slices.ContainsFunc(p.Trust.AllowlistIDs, func(id int64) bool { return id != p.Trust.OperatorID }):
Comment thread
jeremy marked this conversation as resolved.
// An opt-in nobody can use reads as a widening that is not there:
// the operator assigns without one.
return errors.New("admission: allow_assignments is set but the allowlist names nobody besides the operator")
}
switch p.Trust.Mode {
case TrustOperator:
Expand Down
47 changes: 47 additions & 0 deletions internal/connector/setup/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ type Changes struct {
// same run asks for project mode, which admits the project's members as
// participants beside them.
Allow []int64
// AllowAssignments turns the allowlist's assignment opt-in on or off.
// Nil keeps the file's, except in a run that passes Allow, which
// restates the opt-in with the list it covers: off unless set here.
// SetOperator drops a kept one that is left covering nobody.
AllowAssignments *bool

// Serve are the project (bucket) ids to serve. A project already served
// keeps its class and watch_completions.
Expand Down Expand Up @@ -46,6 +51,9 @@ func Apply(f File, ch Changes) (File, error) {
if err := applyTrust(&out.Trust, ch); err != nil {
return File{}, err
}
if err := applyAssignmentOptIn(&out.Trust, ch); err != nil {
return File{}, err
}

for _, id := range ch.Remove {
if slices.Contains(ch.Serve, id) {
Expand Down Expand Up @@ -136,3 +144,42 @@ func sortedIDs(ids []int64) []int64 {
slices.Sort(out)
return slices.Compact(out)
}

// applyAssignmentOptIn sets the opt-in that lets the allowlist's people
// assign the agent work. It rides with the --allow that names them: a run
// that passes --allow restates it, off unless this run sets it, so whoever a
// new list names gains assignments only when asked, with no comparison to
// what the file held before. Setting it needs someone named.
func applyAssignmentOptIn(t *admission.Trust, ch Changes) error {
switch {
case ch.AllowAssignments != nil:
t.AllowAssignments = *ch.AllowAssignments
case len(ch.Allow) > 0:
t.AllowAssignments = false
}
if len(t.AllowlistIDs) == 0 {
if ch.AllowAssignments != nil && *ch.AllowAssignments {
return errors.New("--allow-assignments-from-authorized opts in the people the allowlist names, and after this run it names nobody")
}
t.AllowAssignments = false
}
return nil
}

// SetOperator records the operator. When the allowlist then names nobody
// besides them, the assignment opt-in has nobody left to cover. Kept from an
// earlier run, it is dropped: the operator assigns without one, and keeping
// it would make the file refuse to validate over a change setup itself made.
// Asked for in this run (requested), it is refused, so the person learns it
// does nothing rather than finding it quietly off.
func SetOperator(t *admission.Trust, id int64, requested bool) error {
t.OperatorID = id
if !t.AllowAssignments || slices.ContainsFunc(t.AllowlistIDs, func(a int64) bool { return a != id }) {
return nil
}
if requested {
return errors.New("--allow-assignments-from-authorized covers the people the allowlist names besides the operator, and it names nobody else")
}
t.AllowAssignments = false
return nil
}
Loading
Loading