Skip to content

Commit 58f2c6d

Browse files
Merge pull request #295 from mergestat/github-audit-log
feat: implement a `github_org_audit_log` table
2 parents 5b6c920 + 67d4ea7 commit 58f2c6d

File tree

4 files changed

+298
-0
lines changed

4 files changed

+298
-0
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
version: 1
3+
interactions:
4+
- request:
5+
body: |
6+
{"query":"query($auditLogCursor:String$auditLogOrder:AuditLogOrder$login:String!$perPage:Int!){organization(login: $login){login,auditLog(first: $perPage, after: $auditLogCursor, orderBy: $auditLogOrder){totalCount,nodes{__typename,... on Node{id},... on AuditEntry{action,actor{__typename},actorLogin,actorIp,createdAt,operationType,userLogin}},pageInfo{endCursor,hasNextPage}}}}","variables":{"auditLogCursor":null,"auditLogOrder":null,"login":"mergestat","perPage":50}}
7+
form: {}
8+
headers:
9+
Content-Type:
10+
- application/json
11+
url: https://api.github.com/graphql
12+
method: POST
13+
response:
14+
body: '{"data":{"organization":{"login":"mergestat","auditLog":{"totalCount":39,"nodes":[{"__typename":"RepoCreateAuditEntry","id":"fake-id","action":"repo.create","actor":{"__typename":"User"},"actorLogin":"patrickdevivo","actorIp":"0.0.0.0","createdAt":"2022-06-29T14:06:02.543Z","operationType":"CREATE","userLogin":null}],"pageInfo":{"endCursor":null,"hasNextPage":false}}}}}'
15+
headers:
16+
Access-Control-Allow-Origin:
17+
- '*'
18+
Access-Control-Expose-Headers:
19+
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
20+
X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
21+
X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
22+
X-GitHub-Request-Id, Deprecation, Sunset
23+
Content-Security-Policy:
24+
- default-src 'none'
25+
Content-Type:
26+
- application/json; charset=utf-8
27+
Date:
28+
- Wed, 29 Jun 2022 15:22:01 GMT
29+
Referrer-Policy:
30+
- origin-when-cross-origin, strict-origin-when-cross-origin
31+
Server:
32+
- GitHub.com
33+
Strict-Transport-Security:
34+
- max-age=31536000; includeSubdomains; preload
35+
Vary:
36+
- Accept-Encoding, Accept, X-Requested-With
37+
X-Accepted-Oauth-Scopes:
38+
- repo
39+
X-Content-Type-Options:
40+
- nosniff
41+
X-Frame-Options:
42+
- deny
43+
X-Github-Media-Type:
44+
- github.v4; format=json
45+
X-Github-Request-Id:
46+
- D50C:5249:26D5CB9:574FAA0:62BC6E18
47+
X-Oauth-Scopes:
48+
- admin:enterprise, admin:org, project, repo, user
49+
X-Ratelimit-Limit:
50+
- "5000"
51+
X-Ratelimit-Remaining:
52+
- "4970"
53+
X-Ratelimit-Reset:
54+
- "1656516380"
55+
X-Ratelimit-Resource:
56+
- graphql
57+
X-Ratelimit-Used:
58+
- "30"
59+
X-Xss-Protection:
60+
- "0"
61+
status: 200 OK
62+
code: 200
63+
duration: 1.438554716s

extensions/internal/github/github.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func Register(ext *sqlite.ExtensionApi, opt *options.Options) (_ sqlite.ErrorCod
7979
"github_repo_pr_commits": NewPRCommitsModule(githubOpts),
8080
"github_repo_commits": NewRepoCommitsModule(githubOpts),
8181
"github_repo_pr_reviews": NewPRReviewsModule(githubOpts),
82+
"github_org_audit_log": NewOrgAuditModule(githubOpts),
8283
}
8384

8485
modules["github_issue_comments"] = modules["github_repo_issue_comments"]
@@ -90,6 +91,7 @@ func Register(ext *sqlite.ExtensionApi, opt *options.Options) (_ sqlite.ErrorCod
9091
modules["github_branch_protections"] = modules["github_repo_branch_protections"]
9192
modules["github_pr_commits"] = modules["github_repo_pr_commits"]
9293
modules["github_pr_reviews"] = modules["github_repo_pr_reviews"]
94+
modules["github_audit_log"] = modules["github_org_audit_log"]
9395

9496
// register GitHub tables
9597
for name, mod := range modules {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"io"
6+
"strings"
7+
"time"
8+
9+
"github.com/augmentable-dev/vtab"
10+
"github.com/rs/zerolog"
11+
"github.com/shurcooL/githubv4"
12+
"go.riyazali.net/sqlite"
13+
)
14+
15+
type fetchOrgAuditLogResults struct {
16+
AuditLogs []*auditLogEntry
17+
HasNextPage bool
18+
EndCursor *githubv4.String
19+
}
20+
21+
type auditLogEntry struct {
22+
Typename string `graphql:"__typename"`
23+
NodeFragment struct {
24+
Id string
25+
} `graphql:"... on Node"`
26+
Entry auditLogEntryContents `graphql:"... on AuditEntry"`
27+
}
28+
29+
type auditLogEntryContents struct {
30+
Action string
31+
Actor struct {
32+
Type string `graphql:"__typename"`
33+
}
34+
ActorLogin string
35+
ActorIp string
36+
CreatedAt githubv4.DateTime
37+
OperationType string
38+
UserLogin string
39+
}
40+
41+
func (i *iterOrgAuditLogs) fetchOrgAuditRepos(ctx context.Context, startCursor *githubv4.String) (*fetchOrgAuditLogResults, error) {
42+
var reposQuery struct {
43+
Organization struct {
44+
Login string
45+
AuditLog struct {
46+
TotalCount int
47+
Nodes []*auditLogEntry
48+
PageInfo struct {
49+
EndCursor githubv4.String
50+
HasNextPage bool
51+
}
52+
} `graphql:"auditLog(first: $perPage, after: $auditLogCursor, orderBy: $auditLogOrder)"`
53+
} `graphql:"organization(login: $login)"`
54+
}
55+
variables := map[string]interface{}{
56+
"login": githubv4.String(i.login),
57+
"perPage": githubv4.Int(i.PerPage),
58+
"auditLogCursor": startCursor,
59+
"auditLogOrder": i.auditOrder,
60+
}
61+
62+
err := i.Client().Query(ctx, &reposQuery, variables)
63+
if err != nil {
64+
return nil, err
65+
}
66+
67+
return &fetchOrgAuditLogResults{
68+
reposQuery.Organization.AuditLog.Nodes,
69+
reposQuery.Organization.AuditLog.PageInfo.HasNextPage,
70+
&reposQuery.Organization.AuditLog.PageInfo.EndCursor,
71+
}, nil
72+
73+
}
74+
75+
type iterOrgAuditLogs struct {
76+
*Options
77+
login string
78+
affiliations string
79+
current int
80+
results *fetchOrgAuditLogResults
81+
auditOrder *githubv4.AuditLogOrder
82+
}
83+
84+
func (i *iterOrgAuditLogs) logger() *zerolog.Logger {
85+
logger := i.Logger.With().Int("per-page", i.PerPage).Str("login", i.login).Logger()
86+
if i.auditOrder != nil {
87+
logger = logger.With().Str("order_by", string(*i.auditOrder.Field)).Str("order_dir", string(*i.auditOrder.Direction)).Logger()
88+
}
89+
return &logger
90+
}
91+
92+
func (i *iterOrgAuditLogs) Column(ctx vtab.Context, c int) error {
93+
current := i.results.AuditLogs[i.current]
94+
95+
switch orgAuditCols[c].Name {
96+
case "login":
97+
ctx.ResultText(i.login)
98+
case "id":
99+
ctx.ResultText(current.NodeFragment.Id)
100+
case "entry_type":
101+
ctx.ResultText(current.Typename)
102+
case "action":
103+
ctx.ResultText(current.Entry.Action)
104+
case "actor_type":
105+
ctx.ResultText(current.Entry.Actor.Type)
106+
case "actor_login":
107+
ctx.ResultText(current.Entry.ActorLogin)
108+
case "actor_ip":
109+
ctx.ResultText(current.Entry.ActorIp)
110+
case "created_at":
111+
t := current.Entry.CreatedAt
112+
if t.IsZero() {
113+
ctx.ResultNull()
114+
} else {
115+
ctx.ResultText(t.Format(time.RFC3339Nano))
116+
}
117+
case "operation_type":
118+
ctx.ResultText(current.Entry.OperationType)
119+
case "user_login":
120+
ctx.ResultText(current.Entry.UserLogin)
121+
}
122+
return nil
123+
}
124+
125+
func (i *iterOrgAuditLogs) Next() (vtab.Row, error) {
126+
i.current += 1
127+
128+
if i.results == nil || i.current >= len(i.results.AuditLogs) {
129+
if i.results == nil || i.results.HasNextPage {
130+
err := i.RateLimiter.Wait(context.Background())
131+
if err != nil {
132+
return nil, err
133+
}
134+
135+
var cursor *githubv4.String
136+
if i.results != nil {
137+
cursor = i.results.EndCursor
138+
}
139+
140+
l := i.logger().With().Interface("cursor", cursor).Logger()
141+
l.Info().Msgf("fetching page of org audit entries for %s", i.login)
142+
results, err := i.fetchOrgAuditRepos(context.Background(), cursor)
143+
if err != nil {
144+
return nil, err
145+
}
146+
147+
i.results = results
148+
i.current = 0
149+
150+
} else {
151+
return nil, io.EOF
152+
}
153+
}
154+
155+
return i, nil
156+
}
157+
158+
var orgAuditCols = []vtab.Column{
159+
{Name: "login", Type: "TEXT", Hidden: true, Filters: []*vtab.ColumnFilter{{Op: sqlite.INDEX_CONSTRAINT_EQ, OmitCheck: true}}},
160+
{Name: "id", Type: "TEXT"},
161+
{Name: "entry_type", Type: "TEXT"},
162+
{Name: "action", Type: "TEXT"},
163+
{Name: "actor_type", Type: "TEXT"},
164+
{Name: "actor_login", Type: "TEXT"},
165+
{Name: "actor_ip", Type: "TEXT"},
166+
{Name: "created_at", Type: "DATETIME", OrderBy: vtab.ASC | vtab.DESC},
167+
{Name: "operation_type", Type: "TEXT"},
168+
{Name: "user_login", Type: "TEXT"},
169+
}
170+
171+
func NewOrgAuditModule(opts *Options) sqlite.Module {
172+
return vtab.NewTableFunc("github_audit_repos", orgAuditCols, func(constraints []*vtab.Constraint, orders []*sqlite.OrderBy) (vtab.Iterator, error) {
173+
var login, affiliations string
174+
for _, constraint := range constraints {
175+
if constraint.Op == sqlite.INDEX_CONSTRAINT_EQ {
176+
switch constraint.ColIndex {
177+
case 0:
178+
login = constraint.Value.Text()
179+
180+
case 1:
181+
affiliations = strings.ToUpper(constraint.Value.Text())
182+
}
183+
}
184+
}
185+
186+
var auditOrder *githubv4.AuditLogOrder
187+
// for now we can only support single field order bys
188+
if len(orders) == 1 {
189+
order := orders[0]
190+
switch orgAuditCols[order.ColumnIndex].Name {
191+
case "created_at":
192+
createdAt := githubv4.AuditLogOrderFieldCreatedAt
193+
dir := orderByToGitHubOrder(order.Desc)
194+
auditOrder = &githubv4.AuditLogOrder{
195+
Field: &createdAt,
196+
}
197+
auditOrder.Direction = &dir
198+
}
199+
}
200+
iter := &iterOrgAuditLogs{opts, login, affiliations, -1, nil, auditOrder}
201+
iter.logger().Info().Msgf("starting GitHub audit_log iterator for %s", login)
202+
return iter, nil
203+
}, vtab.EarlyOrderByConstraintExit(true))
204+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package github_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/mergestat/mergestat/extensions/internal/tools"
7+
)
8+
9+
func TestOrgAuditLog(t *testing.T) {
10+
cleanup := newRecorder(t)
11+
defer cleanup()
12+
13+
db := Connect(t, Memory)
14+
15+
rows, err := db.Query("SELECT * FROM github_org_audit_log('mergestat') LIMIT 1")
16+
if err != nil {
17+
t.Fatalf("failed to execute query: %v", err.Error())
18+
}
19+
defer rows.Close()
20+
21+
colCount, _, err := tools.RowContent(rows)
22+
if err != nil {
23+
t.Fatalf("failed to retrieve row contents: %v", err.Error())
24+
}
25+
26+
if expected := 9; colCount != expected {
27+
t.Fatalf("expected %d columns, got: %d", expected, colCount)
28+
}
29+
}

0 commit comments

Comments
 (0)