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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ the connector's dominant cost. It is paginated rather than cached so memory
stays bounded by one page regardless of server size; disgo's rate limiter
handles the resulting request volume.

Channel grants additionally look up each member named by a channel permission
overwrite, because Discord keeps those overwrites after the member leaves and a
grant for a departed user would point at a principal the sync never emitted.
Member overwrites are uncommon next to role overwrites, so this is a small
number of extra requests per channel.

## Known limitations

**Concurrent channel permission changes.** Discord replaces a permission
Expand All @@ -149,10 +155,17 @@ data is correct — but the parent recorded for such an account depends on sync
order and should not be treated as "the" server for that account. Server
membership is authoritatively the `access` grant on each server.

Fields on the account are deliberately account-scoped rather than per-server for
this reason: `created_at` is the account's creation time, decoded from the
snowflake, rather than the per-server join date, and no server ID is recorded on
the profile.

**Rate limiting is handled inside disgo** rather than surfaced to the Baton
SDK, so a sync cannot pace itself against Discord's rate-limit headers or
checkpoint on a 429. disgo blocks and retries, which is correct but opaque to
the syncer.
the syncer. Failures that do surface carry a gRPC status code, so a permission
refusal is distinguishable from a transient one and the SDK's provisioning
retryer can act on the difference.

# Contributing, Support and Issues

Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/quasilyte/go-ruleguard/dsl v0.3.23
go.uber.org/zap v1.28.0
google.golang.org/grpc v1.83.0
)

require (
Expand Down Expand Up @@ -46,7 +47,7 @@ require (
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/pebble/v2 v2.1.5 // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b // indirect
github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
github.com/conductorone/dpop v0.2.6 // indirect
github.com/conductorone/dpop/integrations/dpop_grpc v0.2.4 // indirect
Expand Down Expand Up @@ -137,7 +138,6 @@ require (
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ github.com/cockroachdb/pebble/v2 v2.1.5 h1:1ziHpaSau6qCXnFpQX3EBOH14yPHA8W66vKxs
github.com/cockroachdb/pebble/v2 v2.1.5/go.mod h1:Reo1RTniv1UjVTAu/Fv74y5i3kJ5gmVrPhO9UtFiKn8=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8XTuY0PT9Ane9qZGul/p67vGYwl9BFI=
github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg=
github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 h1:IJ+uNItEm0qx9FE2AgIc1PMsCUtk8nbSIzhQE1t5GWw=
github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/conductorone/baton-sdk v0.24.6 h1:mORfZrBdsxXSYqZxlGMEQTFf6I2fu2/PBF+0c7a73KU=
Expand Down
123 changes: 101 additions & 22 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"

"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/rest"
"github.com/disgoorg/snowflake/v2"
"google.golang.org/grpc/codes"

"github.com/conductorone/baton-sdk/pkg/uhttp"
)

// Discord's maximum page sizes for the cursor-paginated collections used here.
Expand Down Expand Up @@ -46,7 +51,18 @@ func New(ctx context.Context, token string, baseURL string) (*Client, error) {
}

httpClient := &http.Client{Timeout: requestTimeout}
opts := []rest.ClientConfigOpt{rest.WithHTTPClient(httpClient)}
opts := []rest.ClientConfigOpt{
rest.WithHTTPClient(httpClient),
// Pin the logger rather than inheriting slog.Default(). At debug level
// disgo logs every request and response body verbatim, which includes
// the create-invite response carrying the invite code that
// guildBuilder.Grant deliberately keeps out of its errors. Flooring the
// level here makes that guarantee a property of this client instead of
// an accident of whatever the process default happens to be.
rest.WithLogger(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))),
}

if baseURL != "" {
// disgo addresses the API through a single configurable base, so a test
Expand Down Expand Up @@ -80,18 +96,62 @@ func (c *Client) Close() error {
func parseID(kind string, id string) (snowflake.ID, error) {
parsed, err := snowflake.Parse(id)
if err != nil {
return 0, fmt.Errorf("baton-discord: %s ID %q is not a valid Discord snowflake: %w", kind, id, err)
return 0, uhttp.WrapErrors(codes.InvalidArgument,
fmt.Sprintf("baton-discord: %s ID %q is not a valid Discord snowflake", kind, id), err)
}
return parsed, nil
}

// statusCodeFor maps a Discord HTTP status onto the gRPC code the Baton SDK
// reasons about.
//
// Without this every failure reaches the SDK as codes.Unknown, with two
// consequences: C1 cannot tell a 403 role-hierarchy refusal from a transient
// blip, and the provisioning retryer never fires, because it retries only
// Unavailable and DeadlineExceeded.
//
// 5xx maps to Unavailable rather than Internal for exactly that reason —
// Internal is not in the retry set, so classifying a transient upstream failure
// as Internal would describe it accurately and still never retry it.
func statusCodeFor(err error) codes.Code {
statusCode := httpStatus(err)
switch statusCode {
case http.StatusBadRequest:
return codes.InvalidArgument
case http.StatusUnauthorized:
return codes.Unauthenticated
case http.StatusForbidden:
return codes.PermissionDenied
case http.StatusNotFound:
return codes.NotFound
case http.StatusTooManyRequests:
// disgo blocks and retries rate limits internally, so a 429 reaching
// here means it gave up rather than that we raced one request.
return codes.ResourceExhausted
}
if statusCode >= 500 && statusCode < 600 {
return codes.Unavailable
}
return codes.Unknown

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: httpStatus returns 0 whenever the error carries no rest.Error response, so every transport-level failure — the 60s http.Client{Timeout: requestTimeout} firing, connection resets, DNS/TLS errors, context.DeadlineExceeded, context.Canceled — falls through to codes.Unknown. By this function's own reasoning that leaves the most transient class of failure outside the retryer's Unavailable/DeadlineExceeded set, and turns a cancelled sync into an unknown failure. Consider checking errors.Is(err, context.DeadlineExceeded)DeadlineExceeded, context.CanceledCanceled, and mapping a remaining statusCode == 0 to Unavailable.

}

// wrapErr annotates a Discord failure with its gRPC status code while keeping
// the original error unwrappable, so IsNotFound and friends still work.
func wrapErr(err error, format string, args ...any) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(format, args...)
return uhttp.WrapErrors(statusCodeFor(err), msg, fmt.Errorf("%s: %w", msg, err))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: msg is passed twice — once as the WrapErrors message and once more inside the wrapped error — so the rendered error most likely reads "...failed to list roles of guild X: ...failed to list roles of guild X: <cause>". Passing the bare cause (uhttp.WrapErrors(statusCodeFor(err), msg, err)) keeps both the status code and the unwrappability without the duplicated prefix. Worth confirming against the actual output before changing.

}

// CurrentUser returns the bot's own user, the cheapest proof that a token is
// valid. Passing an empty bearer token leaves the client's bot authorization in
// place.
func (c *Client) CurrentUser(ctx context.Context) (*discord.OAuth2User, error) {
user, err := c.rest.GetCurrentUser("", rest.WithCtx(ctx))
if err != nil {
return nil, fmt.Errorf("baton-discord: failed to identify the bot: %w", err)
return nil, wrapErr(err, "baton-discord: failed to identify the bot")
}
return user, nil
}
Expand All @@ -104,7 +164,7 @@ func (c *Client) Guild(ctx context.Context, guildID string) (*discord.RestGuild,
}
guild, err := c.rest.GetGuild(id, false, rest.WithCtx(ctx))
if err != nil {
return nil, fmt.Errorf("baton-discord: failed to get guild %s: %w", guildID, err)
return nil, wrapErr(err, "baton-discord: failed to get guild %s", guildID)
}
return guild, nil
}
Expand All @@ -126,7 +186,7 @@ func (c *Client) GuildsPage(ctx context.Context, after string) ([]discord.OAuth2

guilds, err := c.rest.GetCurrentUserGuilds("", 0, afterID, GuildPageSize, false, rest.WithCtx(ctx))
if err != nil {
return nil, "", fmt.Errorf("baton-discord: failed to list guilds: %w", err)
return nil, "", wrapErr(err, "baton-discord: failed to list guilds")
}

next := ""
Expand Down Expand Up @@ -163,7 +223,7 @@ func (c *Client) MembersPage(ctx context.Context, guildID string, after string)
"baton-discord: not allowed to list members of guild %s; enable the "+
"Server Members Intent on the bot application: %w", guildID, err)
}
return nil, "", fmt.Errorf("baton-discord: failed to list members of guild %s: %w", guildID, err)
return nil, "", wrapErr(err, "baton-discord: failed to list members of guild %s", guildID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the fallthrough on the line below moved to wrapErr, but the IsForbidden branch just above it still uses plain fmt.Errorf. A missing Server Members Intent is the most likely real-world 403 in this connector, and it now reaches the SDK as codes.Unknown rather than PermissionDenied. Wrapping it while keeping the guidance text preserves both: uhttp.WrapErrors(codes.PermissionDenied, msg, fmt.Errorf(msg+": %w", err)).

}

// A member with no user is a misconfiguration, not a member to skip.
Expand All @@ -187,6 +247,29 @@ func (c *Client) MembersPage(ctx context.Context, guildID string, after string)
return members, next, nil
}

// Member returns one guild member. The second result is false when the user is
// not a member of the guild, which Discord reports as a 404.
func (c *Client) Member(ctx context.Context, guildID, userID string) (*discord.Member, bool, error) {
guild, err := parseID("guild", guildID)
if err != nil {
return nil, false, err
}
user, err := parseID("user", userID)
if err != nil {
return nil, false, err
}

member, err := c.rest.GetMember(guild, user, rest.WithCtx(ctx))
if err != nil {
if IsNotFound(err) {
return nil, false, nil
}
return nil, false, fmt.Errorf(
"baton-discord: failed to get member %s of guild %s: %w", userID, guildID, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Member is the one method this PR adds and the one that doesn't go through wrapErr, so its failures still reach the SDK as codes.Unknown. Since it's called per-overwrite inside channelBuilder.Grants, a 403 or a 5xx here is exactly the case the new classification was meant to distinguish. Use wrapErr(err, "baton-discord: failed to get member %s of guild %s", userID, guildID).

}
return member, true, nil
}

// Roles returns every role in a guild. Discord returns this collection whole.
func (c *Client) Roles(ctx context.Context, guildID string) ([]discord.Role, error) {
id, err := parseID("guild", guildID)
Expand All @@ -195,7 +278,7 @@ func (c *Client) Roles(ctx context.Context, guildID string) ([]discord.Role, err
}
roles, err := c.rest.GetRoles(id, rest.WithCtx(ctx))
if err != nil {
return nil, fmt.Errorf("baton-discord: failed to list roles of guild %s: %w", guildID, err)
return nil, wrapErr(err, "baton-discord: failed to list roles of guild %s", guildID)
}
return roles, nil
}
Expand All @@ -208,7 +291,7 @@ func (c *Client) Channels(ctx context.Context, guildID string) ([]discord.GuildC
}
channels, err := c.rest.GetGuildChannels(id, rest.WithCtx(ctx))
if err != nil {
return nil, fmt.Errorf("baton-discord: failed to list channels of guild %s: %w", guildID, err)
return nil, wrapErr(err, "baton-discord: failed to list channels of guild %s", guildID)
}
return channels, nil
}
Expand All @@ -221,7 +304,7 @@ func (c *Client) Channel(ctx context.Context, channelID string) (discord.GuildCh
}
channel, err := c.rest.GetChannel(id, rest.WithCtx(ctx))
if err != nil {
return nil, fmt.Errorf("baton-discord: failed to get channel %s: %w", channelID, err)
return nil, wrapErr(err, "baton-discord: failed to get channel %s", channelID)
}

guildChannel, ok := channel.(discord.GuildChannel)
Expand All @@ -240,8 +323,7 @@ func (c *Client) AddMemberRole(ctx context.Context, guildID, userID, roleID, rea
}
if err := c.rest.AddMemberRole(guild, user, role,
rest.WithCtx(ctx), rest.WithReason(reason)); err != nil {
return fmt.Errorf("baton-discord: failed to add role %s to user %s in guild %s: %w",
roleID, userID, guildID, err)
return wrapErr(err, "baton-discord: failed to add role %s to user %s in guild %s", roleID, userID, guildID)
}
return nil
}
Expand All @@ -254,8 +336,7 @@ func (c *Client) RemoveMemberRole(ctx context.Context, guildID, userID, roleID,
}
if err := c.rest.RemoveMemberRole(guild, user, role,
rest.WithCtx(ctx), rest.WithReason(reason)); err != nil {
return fmt.Errorf("baton-discord: failed to remove role %s from user %s in guild %s: %w",
roleID, userID, guildID, err)
return wrapErr(err, "baton-discord: failed to remove role %s from user %s in guild %s", roleID, userID, guildID)
}
return nil
}
Expand Down Expand Up @@ -287,7 +368,7 @@ func (c *Client) RemoveGuildMember(ctx context.Context, guildID, userID, reason
return err
}
if err := c.rest.RemoveMember(guild, user, rest.WithCtx(ctx), rest.WithReason(reason)); err != nil {
return fmt.Errorf("baton-discord: failed to remove user %s from guild %s: %w", userID, guildID, err)
return wrapErr(err, "baton-discord: failed to remove user %s from guild %s", userID, guildID)
}
return nil
}
Expand Down Expand Up @@ -324,8 +405,7 @@ func (c *Client) SetChannelOverwrite(

if err := c.rest.UpdatePermissionOverwrite(channel, target, update,
rest.WithCtx(ctx), rest.WithReason(reason)); err != nil {
return fmt.Errorf("baton-discord: failed to set permission overwrite for %s on channel %s: %w",
targetID, channelID, err)
return wrapErr(err, "baton-discord: failed to set permission overwrite for %s on channel %s", targetID, channelID)
}
return nil
}
Expand All @@ -342,8 +422,7 @@ func (c *Client) DeleteChannelOverwrite(ctx context.Context, channelID, targetID
}
if err := c.rest.DeletePermissionOverwrite(channel, target,
rest.WithCtx(ctx), rest.WithReason(reason)); err != nil {
return fmt.Errorf("baton-discord: failed to delete permission overwrite for %s on channel %s: %w",
targetID, channelID, err)
return wrapErr(err, "baton-discord: failed to delete permission overwrite for %s on channel %s", targetID, channelID)
}
return nil
}
Expand All @@ -364,15 +443,15 @@ func (c *Client) CreateInvite(ctx context.Context, channelID string, maxAgeSecon
Unique: true,
}, rest.WithCtx(ctx))
if err != nil {
return nil, fmt.Errorf("baton-discord: failed to create an invite for channel %s: %w", channelID, err)
return nil, wrapErr(err, "baton-discord: failed to create an invite for channel %s", channelID)
}
return invite, nil
}

// DeleteInvite revokes an invite so it can no longer be redeemed.
func (c *Client) DeleteInvite(ctx context.Context, code string) error {
if _, err := c.rest.DeleteInvite(code, rest.WithCtx(ctx)); err != nil {
return fmt.Errorf("baton-discord: failed to delete invite: %w", err)
return wrapErr(err, "baton-discord: failed to delete invite")
}
return nil
}
Expand All @@ -386,12 +465,12 @@ func (c *Client) SendDirectMessage(ctx context.Context, userID, content string)

channel, err := c.rest.CreateDMChannel(user, rest.WithCtx(ctx))
if err != nil {
return fmt.Errorf("baton-discord: failed to open a DM channel with user %s: %w", userID, err)
return wrapErr(err, "baton-discord: failed to open a DM channel with user %s", userID)
}

if _, err := c.rest.CreateMessage(channel.ID(), discord.MessageCreate{Content: content},
rest.WithCtx(ctx)); err != nil {
return fmt.Errorf("baton-discord: failed to send a DM to user %s: %w", userID, err)
return wrapErr(err, "baton-discord: failed to send a DM to user %s", userID)
}
return nil
}
Expand Down
26 changes: 26 additions & 0 deletions pkg/connector/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,33 @@ func (c *channelBuilder) Grants(

permissions := permissionsForChannel(channel.Type())

// Member overwrites outlive the membership they were created for: Discord
// keeps them when the targeted member leaves the server. Emitting those
// would produce grants pointing at users that the user listing never
// returned, which the SDK reports as dangling principals. Role overwrites
// need no such check, because a deleted role takes its overwrites with it.
guildID, err := parentGuildID(resource)
if err != nil {
return nil, nil, err
}

var grants []*v2.Grant
for _, overwrite := range channel.PermissionOverwrites() {
target, ok := describeOverwrite(overwrite)
if !ok {
continue
}

if target.ResourceTypeID == userResourceTypeID {
_, stillAMember, err := c.client.Member(ctx, guildID, target.ID)
if err != nil {
return nil, nil, err
}
if !stillAMember {
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this drops a permission overwrite from the sync with no record that it happened. client.IsNotFound matches more than a departed member — it also matches the UnknownGuild/UnknownUser/UnknownChannel JSON codes — so a probe that 404s for a reason other than "left the server" silently removes a real grant, which C1 reads as access revoked. A ctxzap.Extract(ctx).Debug(...) (or Warn) naming the channel and target here would keep the omission attributable.

}
}

principal, err := resource_sdk.NewResourceID(
resourceTypeFor(target.ResourceTypeID), target.ID)
if err != nil {
Expand Down Expand Up @@ -320,6 +340,12 @@ func (c *channelBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations.A
if err := requireResourceType(g.Entitlement.Resource, channelResourceTypeID); err != nil {
return nil, err
}
// A channel overwrite targets a role or a member, so both are valid here,
// but the principal still has to be one of them and has to exist. See
// guildBuilder.Revoke.
if _, err := overwriteIsForRole(g.Principal); err != nil {
return nil, err
}

permission, err := channelPermissionForEntitlement(g.Entitlement)
if err != nil {
Expand Down
Loading
Loading