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
2 changes: 1 addition & 1 deletion cmd/alphone/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ func TestTokenCreateRejectsAnUnreadableLifetime(t *testing.T) {
}, io.Discard)

if err == nil {
t.Error("token() error = nil, want a refusal of an unreadable lifetime")
t.Error("token() error = nil, want an unreadable lifetime refused")
}
}

Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/graphql-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ fell short of. Holding the admin role is not what the field asks for, holding
that capability is, and a plugin-declared role holding it passes just as well.

The `scope` extension still names what the field wanted, so a caller always
learns which area an operation acts in. A refusal about a token's scopes carries
no `capability`, so the two halves stay distinguishable. Minting a wider token
learns which area an operation acts in. An operation refused over a token's
scopes carries no `capability`, so the two halves stay distinguishable. Minting a wider token
does not help here. A token cannot carry more authority than the user it acts
as.

Expand Down
4 changes: 2 additions & 2 deletions internal/graphres/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ var notFoundErrors = []error{
apitoken.ErrNotFound,
}

// spokenAs names every brick refusal in the deployment's own voice.
// spokenAs names every brick error in the deployment's own voice.
var spokenAs = []struct {
sentinel error
message string
Expand All @@ -74,7 +74,7 @@ var spokenAs = []struct {
{role.ErrBeyondReach, "that role is beyond your own"},
}

// speak rewrites a brick refusal so no package name reaches a caller.
// speak rewrites a brick error so no package name reaches a caller.
func speak(presented *gqlerror.Error, err error) {
for _, held := range spokenAs {
if errors.Is(err, held.sentinel) {
Expand Down
4 changes: 2 additions & 2 deletions internal/graphres/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestPresentErrorMapsDomainErrors(t *testing.T) {
}
}

func TestPresentErrorSpeaksTheBrickRefusalsInItsOwnVoice(t *testing.T) {
func TestPresentErrorSpeaksTheBrickErrorsInItsOwnVoice(t *testing.T) {
t.Parallel()

cases := []struct {
Expand Down Expand Up @@ -99,7 +99,7 @@ func TestPresentErrorSpeaksTheBrickRefusalsInItsOwnVoice(t *testing.T) {
}
}

func TestEverySpokenRefusalKeepsItsMessage(t *testing.T) {
func TestEverySpokenErrorKeepsItsMessage(t *testing.T) {
t.Parallel()

for _, spoken := range []error{
Expand Down
20 changes: 10 additions & 10 deletions internal/graphres/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,28 +146,28 @@ func ScopeGate(scopes ScopeMap) graphql.OperationMiddleware {
tier := role.Role(authkit.IdentityFromContext(ctx).Role)
operation := graphql.GetOperationContext(ctx)
if operation.Operation == nil {
return scopeRefusal("the operation")
return scopeError("the operation")
}
kind := operation.Operation.Operation
for _, selected := range graphql.CollectFields(operation, operation.Operation.SelectionSet, nil) {
if carried && !scopes.Allows(kind, selected.Name, token.Scopes) {
return scopeRefusal(scopes.Needed(kind, selected.Name))
return scopeError(scopes.Needed(kind, selected.Name))
}
if needed := scopes.Capability(kind, selected.Name); needed != "" && !role.Can(tier, needed) {
return capabilityRefusal(scopes.Needed(kind, selected.Name), needed)
return capabilityError(scopes.Needed(kind, selected.Name), needed)
}
}
return next(ctx)
}
}

// scopeRefusal answers one operation with the scope its token lacks.
func scopeRefusal(needed string) graphql.ResponseHandler {
return refusal("scope required: "+needed, needed)
// scopeError answers one operation with the scope its token lacks.
func scopeError(needed string) graphql.ResponseHandler {
return refusedWith("scope required: "+needed, needed)
}

// capabilityRefusal answers one operation naming the scope and the capability the caller's role lacked.
func capabilityRefusal(needed string, lacked role.Capability) graphql.ResponseHandler {
// capabilityError answers one operation naming the scope and the capability the caller's role lacked.
func capabilityError(needed string, lacked role.Capability) graphql.ResponseHandler {
return graphql.OneShot(&graphql.Response{Errors: gqlerror.List{&gqlerror.Error{
Message: "admin required",
Extensions: map[string]any{
Expand All @@ -178,8 +178,8 @@ func capabilityRefusal(needed string, lacked role.Capability) graphql.ResponseHa
}}})
}

// refusal answers one operation with the message and the scope the refused field wanted.
func refusal(message, needed string) graphql.ResponseHandler {
// refusedWith answers one operation with the message and the scope the refused field wanted.
func refusedWith(message, needed string) graphql.ResponseHandler {
return graphql.OneShot(&graphql.Response{Errors: gqlerror.List{&gqlerror.Error{
Message: message,
Extensions: map[string]any{"code": "UNAUTHORIZED", "scope": needed},
Expand Down
72 changes: 36 additions & 36 deletions internal/graphres/scopegate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ func gatedWith(t *testing.T, query string, ctx context.Context) *graphql.Respons
return graphres.ScopeGate(graphres.NewScopeMap(schema))(ctx, passed)(ctx)
}

// refusalOf returns the single error message of a refused answer.
func refusalOf(t *testing.T, answered *graphql.Response) string {
// errorOf returns the single error message of a refused answer.
func errorOf(t *testing.T, answered *graphql.Response) string {
t.Helper()
if len(answered.Errors) != 1 {
t.Fatalf("errors = %v, want exactly one refusal", answered.Errors)
t.Fatalf("errors = %v, want exactly one error", answered.Errors)
}
return answered.Errors[0].Message
}
Expand All @@ -99,8 +99,8 @@ func TestScopeGateRefusesAnAdminFieldToAMemberSession(t *testing.T) {

answered := gatedAsRole(t, `mutation { createUser }`, role.Member)

if got, want := refusalOf(t, answered), "admin required"; got != want {
t.Errorf("refusal = %q, want %q", got, want)
if got, want := errorOf(t, answered), "admin required"; got != want {
t.Errorf("error = %q, want %q", got, want)
}
if got := answered.Errors[0].Extensions["code"]; got != "UNAUTHORIZED" {
t.Errorf("code = %v, want UNAUTHORIZED", got)
Expand All @@ -123,13 +123,13 @@ func TestScopeGateNamesTheCapabilityARoleLacks(t *testing.T) {
}
}

func TestAScopeRefusalNamesNoCapability(t *testing.T) {
func TestAScopeErrorNamesNoCapability(t *testing.T) {
t.Parallel()

answered := gatedAsToken(t, `mutation { createContact }`, apitoken.ParseScopes("tasks:read"))

if _, named := answered.Errors[0].Extensions["capability"]; named {
t.Error("a scope refusal named a capability, want the extension only where a role fell short")
t.Error("a scope error named a capability, want the extension only where a role fell short")
}
}

Expand All @@ -139,8 +139,8 @@ func TestScopeGateRefusesEveryUserManagementFieldToAMember(t *testing.T) {
for _, field := range []string{"createUser", "setUserRole", "setUserDisabled"} {
answered := gatedAsRole(t, `mutation { `+field+` }`, role.Member)

if got, want := refusalOf(t, answered), "admin required"; got != want {
t.Errorf("%s refusal = %q, want %q", field, got, want)
if got, want := errorOf(t, answered), "admin required"; got != want {
t.Errorf("%s error = %q, want %q", field, got, want)
}
}
}
Expand All @@ -150,8 +150,8 @@ func TestScopeGateRefusesAFieldWhoseCapabilityTheRoleLacks(t *testing.T) {

answered := gatedAsRole(t, `mutation { needsReports }`, role.Admin)

if got, want := refusalOf(t, answered), "admin required"; got != want {
t.Errorf("refusal = %q, want %q, an admin holding no manage_reports is refused", got, want)
if got, want := errorOf(t, answered), "admin required"; got != want {
t.Errorf("error = %q, want %q, an admin holding no manage_reports is refused", got, want)
}
}

Expand Down Expand Up @@ -180,8 +180,8 @@ func TestScopeGateReadsAnUnstampedSessionAsAMember(t *testing.T) {

answered := gatedWith(t, `mutation { createUser }`, t.Context())

if got, want := refusalOf(t, answered), "admin required"; got != want {
t.Errorf("refusal = %q, want %q, losing the stamp demotes rather than widens", got, want)
if got, want := errorOf(t, answered), "admin required"; got != want {
t.Errorf("error = %q, want %q, losing the stamp demotes rather than widens", got, want)
}
}

Expand Down Expand Up @@ -210,8 +210,8 @@ func TestScopeGateRefusesUserManagementToAnAccountHoldingNoRole(t *testing.T) {

answered := gatedAsRole(t, `mutation { createUser }`, "")

if got, want := refusalOf(t, answered), "admin required"; got != want {
t.Errorf("refusal = %q, want %q", got, want)
if got, want := errorOf(t, answered), "admin required"; got != want {
t.Errorf("error = %q, want %q", got, want)
}
}

Expand All @@ -230,8 +230,8 @@ func TestScopeGateHoldsAWildcardTokenToItsOwnersRole(t *testing.T) {

answered := gatedAsTokenOf(t, `mutation { createUser }`, apitoken.Full(), role.Member)

if got, want := refusalOf(t, answered), "admin required"; got != want {
t.Errorf("refusal = %q, want %q, effective access is the role and the token together", got, want)
if got, want := errorOf(t, answered), "admin required"; got != want {
t.Errorf("error = %q, want %q, effective access is the role and the token together", got, want)
}
}

Expand Down Expand Up @@ -263,8 +263,8 @@ func TestScopeGateRefusesAnAdminsNarrowTokenOnItsScope(t *testing.T) {
answered := gatedAsTokenOf(t,
`mutation { createUser }`, apitoken.ParseScopes("contacts:read"), role.Admin)

if got, want := refusalOf(t, answered), "scope required: users:write"; got != want {
t.Errorf("refusal = %q, want %q, the token check answers first", got, want)
if got, want := errorOf(t, answered), "scope required: users:write"; got != want {
t.Errorf("error = %q, want %q, the token check answers first", got, want)
}
}

Expand All @@ -274,8 +274,8 @@ func TestScopeGateNamesTheScopeBeforeTheTierWhenBothRefuse(t *testing.T) {
answered := gatedAsTokenOf(t,
`mutation { createUser }`, apitoken.ParseScopes("contacts:read"), role.Member)

if got, want := refusalOf(t, answered), "scope required: users:write"; got != want {
t.Errorf("refusal = %q, want %q, the message the n8n docs quote stays put", got, want)
if got, want := errorOf(t, answered), "scope required: users:write"; got != want {
t.Errorf("error = %q, want %q, the message the n8n docs quote stays put", got, want)
}
}

Expand All @@ -294,8 +294,8 @@ func TestScopeGateRefusesAWriteToAReadToken(t *testing.T) {

answered := gatedAsToken(t, `mutation { createContact }`, apitoken.ParseScopes("contacts:read"))

if got := refusalOf(t, answered); !strings.Contains(got, "contacts:write") {
t.Errorf("refusal = %q, want it to name contacts:write", got)
if got := errorOf(t, answered); !strings.Contains(got, "contacts:write") {
t.Errorf("error = %q, want it to name contacts:write", got)
}
if got := answered.Errors[0].Extensions["code"]; got != "UNAUTHORIZED" {
t.Errorf("code = %v, want UNAUTHORIZED", got)
Expand All @@ -309,8 +309,8 @@ func TestScopeGateSeesThroughATopLevelFragmentSpread(t *testing.T) {
`mutation { ...writes } fragment writes on Mutation { createContact }`,
apitoken.ParseScopes("contacts:read"))

if got := refusalOf(t, answered); !strings.Contains(got, "contacts:write") {
t.Errorf("refusal = %q, want a fragment wrapped field checked like any other", got)
if got := errorOf(t, answered); !strings.Contains(got, "contacts:write") {
t.Errorf("error = %q, want a fragment wrapped field checked like any other", got)
}
}

Expand All @@ -319,8 +319,8 @@ func TestScopeGateSeesThroughAnInlineFragment(t *testing.T) {

answered := gatedAsToken(t, `{ ... on Query { contacts } }`, apitoken.ParseScopes("tasks:read"))

if got := refusalOf(t, answered); !strings.Contains(got, "contacts:read") {
t.Errorf("refusal = %q, want an inline fragment checked like any other", got)
if got := errorOf(t, answered); !strings.Contains(got, "contacts:read") {
t.Errorf("error = %q, want an inline fragment checked like any other", got)
}
}

Expand All @@ -331,8 +331,8 @@ func TestScopeGateSeesThroughANestedFragmentSpread(t *testing.T) {
`{ ...outer } fragment outer on Query { ...inner } fragment inner on Query { contacts }`,
apitoken.ParseScopes("tasks:read"))

if got := refusalOf(t, answered); !strings.Contains(got, "contacts:read") {
t.Errorf("refusal = %q, want nesting to be no escape", got)
if got := errorOf(t, answered); !strings.Contains(got, "contacts:read") {
t.Errorf("error = %q, want nesting to be no escape", got)
}
}

Expand All @@ -341,8 +341,8 @@ func TestScopeGateRefusesEveryFieldOfAMixedOperation(t *testing.T) {

answered := gatedAsToken(t, `{ contacts me }`, apitoken.ParseScopes("tasks:read"))

if got := refusalOf(t, answered); !strings.Contains(got, "contacts:read") {
t.Errorf("refusal = %q, want the unheld field to refuse the whole operation", got)
if got := errorOf(t, answered); !strings.Contains(got, "contacts:read") {
t.Errorf("error = %q, want the unheld field to refuse the whole operation", got)
}
}

Expand Down Expand Up @@ -371,8 +371,8 @@ func TestScopeGateRefusesTokenManagementToAWildcardToken(t *testing.T) {

answered := gatedAsToken(t, `{ apiTokens }`, apitoken.Full())

if got := refusalOf(t, answered); !strings.Contains(got, "tokens:read") {
t.Errorf("refusal = %q, want token management to need a session", got)
if got := errorOf(t, answered); !strings.Contains(got, "tokens:read") {
t.Errorf("error = %q, want token management to need a session", got)
}
}

Expand All @@ -381,8 +381,8 @@ func TestScopeGateRefusesAFieldTheSchemaDoesNotScope(t *testing.T) {

answered := gatedAsToken(t, `{ unscoped }`, apitoken.Full())

if got := refusalOf(t, answered); !strings.Contains(got, "unscoped") {
t.Errorf("refusal = %q, want an unscoped field refused, the gate fails closed", got)
if got := errorOf(t, answered); !strings.Contains(got, "unscoped") {
t.Errorf("error = %q, want an unscoped field refused, the gate fails closed", got)
}
}

Expand All @@ -395,6 +395,6 @@ func TestScopeGateRefusesAnOperationItCannotRead(t *testing.T) {
answered := graphres.ScopeGate(graphres.NewScopeMap(loadScopedSchema(t)))(ctx, passed)(ctx)

if len(answered.Errors) != 1 {
t.Errorf("errors = %v, want a refusal when there is no operation to read", answered.Errors)
t.Errorf("errors = %v, want an error when there is no operation to read", answered.Errors)
}
}
4 changes: 2 additions & 2 deletions internal/graphres/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func TestAPITokenCreateRefusesAnAreaNoSchemaDeclares(t *testing.T) {
t.Errorf("code = %q, want VALIDATION", got)
}
if !strings.Contains(string(answered.Errors), `contact`) {
t.Errorf("errors = %s, want the refusal to name the area", answered.Errors)
t.Errorf("errors = %s, want the error to name the area", answered.Errors)
}
held, err := tokens.ListForUser(t.Context(), owner)
if err != nil {
Expand Down Expand Up @@ -340,6 +340,6 @@ func TestAPITokenRevokeRefusesSomeoneElsesToken(t *testing.T) {
t.Fatalf("RawPost() error = %v, want nil", err)
}
if len(answered.Errors) == 0 {
t.Error("errors = none, want a refusal for a token the caller does not own")
t.Error("errors = none, want an error for a token the caller does not own")
}
}
8 changes: 4 additions & 4 deletions internal/mcp/contacts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,26 +189,26 @@ func TestContactRefusesAnIdNoContactHolds(t *testing.T) {
}
}

func TestContactReportsAGraphRefusal(t *testing.T) {
func TestContactReportsAGraphError(t *testing.T) {
t.Parallel()

run := &tools{graph: answering(`{"errors":[{"message":"scalar: invalid value"}]}`)}

_, _, err := run.contact(t.Context(), ContactInput{ContactID: "not-a-uuid"})

if err == nil || !strings.Contains(err.Error(), "invalid value") {
t.Errorf("error = %v, want the refusal", err)
t.Errorf("error = %v, want the error", err)
}
}

func TestContactsReportsAGraphRefusal(t *testing.T) {
func TestContactsReportsAGraphError(t *testing.T) {
t.Parallel()

run := &tools{graph: answering(`{"errors":[{"message":"refused","extensions":{"code":"VALIDATION"}}]}`)}

_, _, err := run.contacts(t.Context(), ContactsInput{})

if err == nil || !strings.Contains(err.Error(), "refused") {
t.Errorf("error = %v, want the refusal", err)
t.Errorf("error = %v, want the error", err)
}
}
4 changes: 2 additions & 2 deletions internal/mcp/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import (
// graphPath is where every tool posts its operation.
const graphPath = "/api/graphql"

// graphError is one refusal the graph answered with.
// graphError is one error the graph answered with.
type graphError struct {
Message string `json:"message"`
Extensions map[string]any `json:"extensions"`
}

// Error names the refusal beside its code.
// Error names the error beside its code.
func (e graphError) Error() string {
if code, ok := e.Extensions["code"].(string); ok && code != "" {
return fmt.Sprintf("%s (%s)", e.Message, code)
Expand Down
6 changes: 3 additions & 3 deletions internal/mcp/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestRunReportsTheFirstGraphError(t *testing.T) {
err := run.execute(t.Context(), "{ task }", nil, &struct{}{})

if err == nil {
t.Fatal("execute() error = nil, want the graph refusal")
t.Fatal("execute() error = nil, want the graph error")
}
if !strings.Contains(err.Error(), "task: not found") {
t.Errorf("error = %v, want the message", err)
Expand All @@ -83,11 +83,11 @@ func TestRunReportsTheFirstGraphError(t *testing.T) {
func TestRunReportsAGraphErrorWithoutACode(t *testing.T) {
t.Parallel()

run := &tools{graph: answering(`{"errors":[{"message":"plain refusal"}]}`)}
run := &tools{graph: answering(`{"errors":[{"message":"plain error"}]}`)}

err := run.execute(t.Context(), "{ task }", nil, &struct{}{})

if err == nil || err.Error() != "plain refusal" {
if err == nil || err.Error() != "plain error" {
t.Errorf("error = %v, want the bare message", err)
}
}
Expand Down
Loading
Loading