Skip to content

Commit bf4b60a

Browse files
Add Sentry org auto-discovery and 1Password Users API driver
Allow Sentry driver to auto-discover the organization slug from the OAuth token when not explicitly configured. Add a new OnePasswordUsersAPIDriver that fetches users from the 1Password Users API v1beta1 with paginated requests. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
1 parent 1e794ad commit bf4b60a

File tree

2 files changed

+193
-1
lines changed

2 files changed

+193
-1
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
2+
//
3+
// Permission to use, copy, modify, and/or distribute this software for any
4+
// purpose with or without fee is hereby granted, provided that the above
5+
// copyright notice and this permission notice appear in all copies.
6+
//
7+
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8+
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
9+
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10+
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
11+
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
12+
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
13+
// PERFORMANCE OF THIS SOFTWARE.
14+
15+
package drivers
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"net/http"
22+
"time"
23+
24+
"go.probo.inc/probo/pkg/coredata"
25+
)
26+
27+
// OnePasswordUsersAPIDriver fetches user accounts from the 1Password
28+
// Users API (v1beta1). This is distinct from the SCIM-based
29+
// OnePasswordDriver and uses the native 1Password API with
30+
// token-based pagination.
31+
type OnePasswordUsersAPIDriver struct {
32+
httpClient *http.Client
33+
baseURL string
34+
accountID string
35+
}
36+
37+
func NewOnePasswordUsersAPIDriver(httpClient *http.Client, accountID string, region string) *OnePasswordUsersAPIDriver {
38+
return &OnePasswordUsersAPIDriver{
39+
httpClient: httpClient,
40+
baseURL: onePasswordBaseURL(region),
41+
accountID: accountID,
42+
}
43+
}
44+
45+
func onePasswordBaseURL(region string) string {
46+
switch region {
47+
case "CA", "ca":
48+
return "https://api.1password.ca"
49+
case "EU", "eu":
50+
return "https://api.1password.eu"
51+
default:
52+
return "https://api.1password.com"
53+
}
54+
}
55+
56+
func (d *OnePasswordUsersAPIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
57+
var (
58+
records []AccountRecord
59+
pageToken string
60+
)
61+
62+
for range maxPaginationPages {
63+
resp, err := d.queryUsers(ctx, pageToken)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
for _, u := range resp.Users {
69+
record := AccountRecord{
70+
Email: u.Email,
71+
FullName: u.DisplayName,
72+
Active: u.State == "ACTIVE",
73+
ExternalID: u.ID,
74+
MFAStatus: coredata.MFAStatusUnknown,
75+
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
76+
AccountType: coredata.AccessEntryAccountTypeUser,
77+
}
78+
79+
if u.CreateTime != "" {
80+
if t, err := time.Parse(time.RFC3339, u.CreateTime); err == nil {
81+
record.CreatedAt = &t
82+
}
83+
}
84+
85+
if record.Email != "" {
86+
records = append(records, record)
87+
}
88+
}
89+
90+
if resp.NextPageToken == "" {
91+
return records, nil
92+
}
93+
pageToken = resp.NextPageToken
94+
}
95+
96+
return nil, fmt.Errorf("cannot list all 1password users api accounts: %w", ErrPaginationLimitReached)
97+
}
98+
99+
func (d *OnePasswordUsersAPIDriver) queryUsers(ctx context.Context, pageToken string) (*onePasswordUsersAPIResponse, error) {
100+
url := fmt.Sprintf(
101+
"%s/v1beta1/accounts/%s/users",
102+
d.baseURL,
103+
d.accountID,
104+
)
105+
106+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
107+
if err != nil {
108+
return nil, fmt.Errorf("cannot create 1password users api request: %w", err)
109+
}
110+
111+
q := req.URL.Query()
112+
q.Set("max_page_size", "100")
113+
if pageToken != "" {
114+
q.Set("page_token", pageToken)
115+
}
116+
req.URL.RawQuery = q.Encode()
117+
118+
req.Header.Set("Accept", "application/json")
119+
120+
httpResp, err := d.httpClient.Do(req)
121+
if err != nil {
122+
return nil, fmt.Errorf("cannot execute 1password users api request: %w", err)
123+
}
124+
defer func() {
125+
_ = httpResp.Body.Close()
126+
}()
127+
128+
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
129+
return nil, fmt.Errorf("cannot fetch 1password users api: unexpected status %d", httpResp.StatusCode)
130+
}
131+
132+
var resp onePasswordUsersAPIResponse
133+
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
134+
return nil, fmt.Errorf("cannot decode 1password users api response: %w", err)
135+
}
136+
137+
return &resp, nil
138+
}
139+
140+
type onePasswordUsersAPIResponse struct {
141+
Users []onePasswordUsersAPIUser `json:"users"`
142+
NextPageToken string `json:"next_page_token"`
143+
}
144+
145+
type onePasswordUsersAPIUser struct {
146+
ID string `json:"id"`
147+
Email string `json:"email"`
148+
DisplayName string `json:"display_name"`
149+
State string `json:"state"`
150+
CreateTime string `json:"create_time"`
151+
Path string `json:"path"`
152+
}

pkg/accessreview/drivers/sentry.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,52 @@ func NewSentryDriver(httpClient *http.Client, orgSlug string) *SentryDriver {
3939
}
4040
}
4141

42+
func (d *SentryDriver) resolveOrgSlug(ctx context.Context) (string, error) {
43+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://sentry.io/api/0/organizations/", nil)
44+
if err != nil {
45+
return "", fmt.Errorf("cannot create sentry organizations request: %w", err)
46+
}
47+
req.Header.Set("Accept", "application/json")
48+
49+
resp, err := d.httpClient.Do(req)
50+
if err != nil {
51+
return "", fmt.Errorf("cannot fetch sentry organizations: %w", err)
52+
}
53+
defer func() { _ = resp.Body.Close() }()
54+
55+
if resp.StatusCode != http.StatusOK {
56+
return "", fmt.Errorf("cannot fetch sentry organizations: status %d", resp.StatusCode)
57+
}
58+
59+
var orgs []struct {
60+
Slug string `json:"slug"`
61+
}
62+
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
63+
return "", fmt.Errorf("cannot decode sentry organizations response: %w", err)
64+
}
65+
66+
if len(orgs) == 0 {
67+
return "", fmt.Errorf("no sentry organizations found for this token")
68+
}
69+
70+
return orgs[0].Slug, nil
71+
}
72+
4273
func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
74+
orgSlug := d.orgSlug
75+
if orgSlug == "" {
76+
slug, err := d.resolveOrgSlug(ctx)
77+
if err != nil {
78+
return nil, fmt.Errorf("cannot resolve sentry organization slug: %w", err)
79+
}
80+
orgSlug = slug
81+
}
82+
4383
var records []AccountRecord
4484

4585
nextURL := fmt.Sprintf(
4686
"https://sentry.io/api/0/organizations/%s/members/",
47-
d.orgSlug,
87+
orgSlug,
4888
)
4989

5090
for range maxPaginationPages {

0 commit comments

Comments
 (0)