-
Notifications
You must be signed in to change notification settings - Fork 0
fix: expose M365 read-only pilot CLI #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "strings" | ||
|
|
||
| "github.com/automagik-dev/workit/internal/googleauth" | ||
| "github.com/automagik-dev/workit/internal/msauth" | ||
| ) | ||
|
|
||
| type authServiceInfo struct { | ||
| Service string `json:"service"` | ||
| User bool `json:"user"` | ||
| Scopes []string `json:"scopes"` | ||
| APIs []string `json:"apis,omitempty"` | ||
| Note string `json:"note,omitempty"` | ||
| } | ||
|
|
||
| func appendAuthServiceInfos(googleInfos []googleauth.ServiceInfo, m365Infos []msauth.ServiceInfo) []authServiceInfo { | ||
| out := make([]authServiceInfo, 0, len(googleInfos)+len(m365Infos)) | ||
| for _, info := range googleInfos { | ||
| out = append(out, authServiceInfo{ | ||
| Service: string(info.Service), | ||
| User: info.User, | ||
| Scopes: append([]string(nil), info.Scopes...), | ||
| APIs: append([]string(nil), info.APIs...), | ||
| Note: info.Note, | ||
| }) | ||
| } | ||
| for _, info := range m365Infos { | ||
| out = append(out, authServiceInfo{ | ||
| Service: info.Service, | ||
| User: info.User, | ||
| Scopes: append([]string(nil), info.Scopes...), | ||
| APIs: append([]string(nil), info.APIs...), | ||
| Note: info.Note, | ||
| }) | ||
| } | ||
|
|
||
| return out | ||
| } | ||
|
|
||
| func authServicesMarkdown(infos []authServiceInfo) string { | ||
| if len(infos) == 0 { | ||
| return "" | ||
| } | ||
|
|
||
| var b strings.Builder | ||
| b.WriteString("| Service | User | APIs | Scopes | Notes |\n") | ||
| b.WriteString("| --- | --- | --- | --- | --- |\n") | ||
| for _, info := range infos { | ||
| userLabel := "no" | ||
| if info.User { | ||
| userLabel = "yes" | ||
| } | ||
| b.WriteString("| ") | ||
| b.WriteString(info.Service) | ||
| b.WriteString(" | ") | ||
| b.WriteString(userLabel) | ||
| b.WriteString(" | ") | ||
| b.WriteString(strings.Join(info.APIs, ", ")) | ||
| b.WriteString(" | ") | ||
| b.WriteString(markdownAuthScopes(info.Scopes)) | ||
| b.WriteString(" | ") | ||
| b.WriteString(info.Note) | ||
| b.WriteString(" |\n") | ||
| } | ||
|
|
||
| return b.String() | ||
| } | ||
|
|
||
| func markdownAuthScopes(scopes []string) string { | ||
| if len(scopes) == 0 { | ||
| return "" | ||
| } | ||
|
|
||
| parts := make([]string, 0, len(scopes)) | ||
| for _, scope := range scopes { | ||
| parts = append(parts, "`"+scope+"`") | ||
| } | ||
|
|
||
| return strings.Join(parts, "<br>") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "strings" | ||
|
|
||
| "github.com/automagik-dev/workit/internal/ui" | ||
| ) | ||
|
|
||
| type M365Cmd struct { | ||
| Outlook M365OutlookCmd `cmd:"" help:"Microsoft Outlook read-only pilot commands"` | ||
| Calendar M365CalendarCmd `cmd:"" help:"Microsoft Calendar read-only pilot commands"` | ||
| } | ||
|
|
||
| type M365OutlookCmd struct { | ||
| Search M365OutlookSearchCmd `cmd:"" help:"Search Outlook messages (read-only pilot)"` | ||
| Message M365OutlookMessageCmd `cmd:"" help:"Read Outlook messages (read-only pilot)"` | ||
| } | ||
|
|
||
| type M365OutlookSearchCmd struct { | ||
| Query string `name:"query" help:"Microsoft Graph message search query"` | ||
| Top int `name:"top" help:"Maximum messages to return" default:"10"` | ||
| } | ||
|
|
||
| type M365OutlookMessageCmd struct { | ||
| Get M365OutlookMessageGetCmd `cmd:"" help:"Get an Outlook message by id (read-only pilot)"` | ||
| } | ||
|
|
||
| type M365OutlookMessageGetCmd struct { | ||
| ID string `arg:"" name:"id" help:"Microsoft Graph message id"` | ||
| } | ||
|
|
||
| type M365CalendarCmd struct { | ||
| Events M365CalendarEventsCmd `cmd:"" help:"List calendar events (read-only pilot)"` | ||
| FreeBusy M365CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Check free/busy availability (read-only pilot)"` | ||
| } | ||
|
|
||
| type M365CalendarEventsCmd struct { | ||
| From string `name:"from" help:"Start time (RFC3339)"` | ||
| To string `name:"to" help:"End time (RFC3339)"` | ||
| Top int `name:"top" help:"Maximum events to return" default:"10"` | ||
| } | ||
|
|
||
| type M365CalendarFreeBusyCmd struct { | ||
| Users string `name:"users" help:"Comma-separated email addresses/resources"` | ||
| From string `name:"from" help:"Start time (RFC3339)"` | ||
| To string `name:"to" help:"End time (RFC3339)"` | ||
| } | ||
|
|
||
| func (c *M365OutlookSearchCmd) Run(ctx context.Context, flags *RootFlags) error { | ||
| return writeM365PilotResult(ctx, flags, "m365.outlook.search", map[string]any{ | ||
| "query": strings.TrimSpace(c.Query), | ||
| "top": c.Top, | ||
| }) | ||
| } | ||
|
|
||
| func (c *M365OutlookMessageGetCmd) Run(ctx context.Context, flags *RootFlags) error { | ||
| return writeM365PilotResult(ctx, flags, "m365.outlook.message.get", map[string]any{ | ||
| "id": strings.TrimSpace(c.ID), | ||
| }) | ||
| } | ||
|
|
||
| func (c *M365CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { | ||
| return writeM365PilotResult(ctx, flags, "m365.calendar.events", map[string]any{ | ||
| "from": strings.TrimSpace(c.From), | ||
| "to": strings.TrimSpace(c.To), | ||
| "top": c.Top, | ||
| }) | ||
| } | ||
|
|
||
| func (c *M365CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error { | ||
| users := splitCommaList(c.Users) | ||
| if users == nil { | ||
| users = []string{} | ||
| } | ||
|
|
||
| return writeM365PilotResult(ctx, flags, "m365.calendar.freebusy", map[string]any{ | ||
| "users": users, | ||
| "from": strings.TrimSpace(c.From), | ||
| "to": strings.TrimSpace(c.To), | ||
| }) | ||
| } | ||
|
|
||
| func writeM365PilotResult(ctx context.Context, flags *RootFlags, operation string, request map[string]any) error { | ||
| if flags == nil || !flags.ReadOnly { | ||
| return usage("m365 pilot commands require explicit --read-only") | ||
| } | ||
|
Comment on lines
+85
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| u := ui.FromContext(ctx) | ||
| return writeResult(ctx, u, | ||
| kv("operation", operation), | ||
| kv("provider", "microsoft_graph"), | ||
| kv("mode", "read_only_pilot"), | ||
| kv("status", "ready_for_m365_auth"), | ||
| kv("request", request), | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "slices" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
Comment on lines
+3
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| func TestM365ReadOnlyPilotCommandsAreExposed(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| args []string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "outlook search", | ||
| args: []string{"--json", "--read-only", "m365", "outlook", "search", "--query", "from:felipe"}, | ||
| want: "m365.outlook.search", | ||
| }, | ||
| { | ||
| name: "outlook message get", | ||
| args: []string{"--json", "--read-only", "m365", "outlook", "message", "get", "AAMk-message-id"}, | ||
| want: "m365.outlook.message.get", | ||
| }, | ||
| { | ||
| name: "calendar events", | ||
| args: []string{"--json", "--read-only", "m365", "calendar", "events", "--from", "2026-05-31T00:00:00Z", "--to", "2026-06-01T00:00:00Z"}, | ||
| want: "m365.calendar.events", | ||
| }, | ||
| { | ||
| name: "calendar freebusy", | ||
| args: []string{"--json", "--read-only", "m365", "calendar", "freebusy", "--users", "bernardo@example.com,felipe@example.com"}, | ||
| want: "m365.calendar.freebusy", | ||
| }, | ||
| { | ||
| name: "calendar freebusy without users", | ||
| args: []string{"--json", "--read-only", "m365", "calendar", "freebusy"}, | ||
| want: "m365.calendar.freebusy", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| out := captureStdout(t, func() { | ||
| _ = captureStderr(t, func() { | ||
| if err := Execute(tt.args); err != nil { | ||
| t.Fatalf("Execute(%v): %v", tt.args, err) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| var got map[string]any | ||
| if err := json.Unmarshal([]byte(out), &got); err != nil { | ||
| t.Fatalf("json output: %v\n%s", err, out) | ||
| } | ||
| if got["operation"] != tt.want { | ||
| t.Fatalf("operation = %v, want %s; output=%s", got["operation"], tt.want, out) | ||
| } | ||
| if got["provider"] != "microsoft_graph" { | ||
| t.Fatalf("provider = %v, want microsoft_graph; output=%s", got["provider"], out) | ||
| } | ||
| if got["mode"] != "read_only_pilot" { | ||
| t.Fatalf("mode = %v, want read_only_pilot; output=%s", got["mode"], out) | ||
| } | ||
| if tt.name == "calendar freebusy without users" { | ||
| request, ok := got["request"].(map[string]any) | ||
| if !ok { | ||
| t.Fatalf("request has type %T, want object; output=%s", got["request"], out) | ||
| } | ||
| users, ok := request["users"].([]any) | ||
| if !ok { | ||
| t.Fatalf("request.users has type %T, want empty array; output=%s", request["users"], out) | ||
| } | ||
| if len(users) != 0 { | ||
| t.Fatalf("request.users = %#v, want empty array", users) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestM365PilotCommandsRequireExplicitReadOnlyFlag(t *testing.T) { | ||
| _ = captureStderr(t, func() { | ||
| err := Execute([]string{"--json", "m365", "outlook", "search", "--query", "from:felipe"}) | ||
| if err == nil { | ||
| t.Fatal("expected m365 pilot command without --read-only to fail closed") | ||
| } | ||
| if !strings.Contains(err.Error(), "--read-only") { | ||
| t.Fatalf("expected --read-only error, got: %v", err) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestAuthServicesJSONIncludesM365PilotReadOnlyScopes(t *testing.T) { | ||
| out := captureStdout(t, func() { | ||
| _ = captureStderr(t, func() { | ||
| if err := Execute([]string{"--json", "auth", "services"}); err != nil { | ||
| t.Fatalf("auth services: %v", err) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| var payload struct { | ||
| Services []struct { | ||
| Service string `json:"service"` | ||
| Scopes []string `json:"scopes"` | ||
| } `json:"services"` | ||
| } | ||
| if err := json.Unmarshal([]byte(out), &payload); err != nil { | ||
| t.Fatalf("json output: %v\n%s", err, out) | ||
| } | ||
|
|
||
| var scopes []string | ||
| for _, service := range payload.Services { | ||
| if service.Service == "m365" { | ||
| scopes = service.Scopes | ||
| break | ||
| } | ||
| } | ||
| if len(scopes) == 0 { | ||
| t.Fatalf("auth services missing m365 service: %s", out) | ||
| } | ||
| for _, scope := range []string{"User.Read", "Mail.Read", "Calendars.Read"} { | ||
| if !slices.Contains(scopes, scope) { | ||
| t.Fatalf("m365 auth services missing %s: %#v", scope, scopes) | ||
| } | ||
| } | ||
| for _, forbidden := range []string{"Mail.Send", "Calendars.ReadWrite"} { | ||
| if slices.Contains(scopes, forbidden) { | ||
| t.Fatalf("m365 auth services exposed write scope %s: %#v", forbidden, scopes) | ||
| } | ||
| } | ||
|
Comment on lines
+124
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can use the standard library's \tfor _, scope := range []string{\"User.Read\", \"Mail.Read\", \"Calendars.Read\"} {\n\t\tif !slices.Contains(scopes, scope) {\n\t\t\tt.Fatalf(\"m365 auth services missing %s: %#v\", scope, scopes)\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\"Mail.Send\", \"Calendars.ReadWrite\"} {\n\t\tif slices.Contains(scopes, forbidden) {\n\t\t\tt.Fatalf(\"m365 auth services exposed write scope %s: %#v\", forbidden, scopes)\n\t\t}\n\t} |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package msauth | ||
|
|
||
| type ServiceInfo struct { | ||
| Service string `json:"service"` | ||
| User bool `json:"user"` | ||
| Scopes []string `json:"scopes"` | ||
| APIs []string `json:"apis,omitempty"` | ||
| Note string `json:"note,omitempty"` | ||
| } | ||
|
|
||
| func ServicesInfo() []ServiceInfo { | ||
| return []ServiceInfo{ | ||
| { | ||
| Service: "m365", | ||
| User: true, | ||
| Scopes: PilotAllowedScopes(), | ||
| APIs: []string{"Microsoft Graph"}, | ||
| Note: "Read-only Microsoft 365 pilot; writes remain KHAW-gated/disabled", | ||
| }, | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
c.Usersis empty,splitCommaListreturnsnil, which marshals tonullin the JSON output. It is more idiomatic for JSON arrays to be represented as an empty array[]rather thannullwhen empty. We can ensure a non-nil slice is returned.