package apitokens import ( "net/http" "strings" ) type ScopeHint int const ( ScopeNone ScopeHint = iota ScopeMailAccountQuery ScopeMailAccountPath ScopeDrivePathFromURL ) type Requirement struct { Resource string Alternatives []string Write bool ScopeHint ScopeHint } func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bool) { method = strings.ToUpper(strings.TrimSpace(method)) path := strings.TrimSuffix(strings.TrimSpace(fullPath), "/") if path == "" { path = "/" } write := method != http.MethodGet && method != http.MethodHead switch { case strings.HasPrefix(path, "/api/v1/ai/chat/completions"), strings.HasPrefix(path, "/api/v1/ai/v1/chat/completions"): return Requirement{Resource: "automation.chat", Write: true}, true case strings.HasPrefix(path, "/api/v1/ai/sessions"), strings.HasPrefix(path, "/api/v1/ai/chats/sync"): return Requirement{Resource: "automation.chat", Write: true}, true case strings.HasPrefix(path, "/api/v1/ai/chats/"): if write || method == http.MethodDelete { return Requirement{Resource: "automation.chat", Write: true}, true } return Requirement{Resource: "automation.chat", Write: false}, true case strings.HasPrefix(path, "/api/v1/ai/quota"), strings.HasPrefix(path, "/api/v1/ai/models"): return Requirement{Resource: "automation.chat", Write: false}, true case strings.HasPrefix(path, "/api/v1/mail/api-tokens"): return Requirement{Resource: "automation.api_tokens", Write: write || method == http.MethodDelete}, true case strings.HasPrefix(path, "/api/v1/mail/webhooks"): return Requirement{Resource: "automation.webhooks", Write: write}, true case strings.HasPrefix(path, "/api/v1/mail/rules"): return Requirement{Resource: "automation.rules", Write: write}, true case strings.HasPrefix(path, "/api/v1/contacts/discovery/llm-settings"), strings.HasPrefix(path, "/api/v1/contacts/discovery/llm-models/"): return Requirement{Resource: "automation.llm", Write: write}, true case strings.HasPrefix(path, "/api/v1/contacts/discovery/search-settings"): return Requirement{Resource: "automation.search", Write: write}, true case strings.HasPrefix(path, "/api/v1/contacts/discovery/"): return Requirement{Resource: "contacts.write", Write: write}, true case strings.HasPrefix(path, "/api/v1/contacts/search"): return Requirement{Resource: "contacts.search", Write: false}, true case strings.HasPrefix(path, "/api/v1/contacts/"): switch method { case http.MethodPost, http.MethodPut, http.MethodPatch: if strings.Contains(path, "/merge-duplicates") || strings.Contains(path, "/improve") { return Requirement{Resource: "contacts.write", Write: true}, true } if strings.Contains(path, "/books/") { return Requirement{Resource: "contacts.write", Write: true}, true } return Requirement{Resource: "contacts.write", Write: true}, true case http.MethodDelete: return Requirement{Resource: "contacts.delete", Write: true}, true default: return Requirement{Resource: "contacts.read", Write: false}, true } case strings.HasPrefix(path, "/api/v1/drive/"): return driveRequirement(method, path) case strings.HasPrefix(path, "/api/v1/richtext/"): return richtextRequirement(method, path) case strings.HasPrefix(path, "/api/v1/search"): return searchRequirement(typesQuery) case strings.HasPrefix(path, "/api/v1/mail/"): return mailRequirement(method, path) } return Requirement{}, false } func mailRequirement(method, path string) (Requirement, bool) { write := method != http.MethodGet && method != http.MethodHead switch { case strings.HasPrefix(path, "/api/v1/mail/settings"): return Requirement{Resource: "mail.settings", Write: write}, true case strings.HasPrefix(path, "/api/v1/mail/search"): return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true case strings.HasPrefix(path, "/api/v1/mail/send"), strings.HasPrefix(path, "/api/v1/mail/outbox/"): return Requirement{Resource: "mail.send", Write: true}, true case strings.HasPrefix(path, "/api/v1/mail/signatures"): return Requirement{Resource: "mail.settings", Write: write}, true case strings.HasPrefix(path, "/api/v1/mail/identities/"): return Requirement{Resource: "mail.identities", Write: write}, true case strings.Contains(path, "/accounts/") && strings.Contains(path, "/identities"): return Requirement{Resource: "mail.identities", Write: write, ScopeHint: ScopeMailAccountPath}, true case strings.HasPrefix(path, "/api/v1/mail/accounts"): if write { return Requirement{Resource: "mail.settings", Write: true, ScopeHint: ScopeMailAccountPath}, true } return Requirement{Resource: "mail.mailboxes", Write: false, ScopeHint: ScopeMailAccountPath}, true case strings.HasPrefix(path, "/api/v1/mail/unified-folders"), strings.HasPrefix(path, "/api/v1/mail/folders"): return Requirement{Resource: "mail.mailboxes", Write: write, ScopeHint: ScopeMailAccountQuery}, true case strings.HasPrefix(path, "/api/v1/mail/labels"): return Requirement{Resource: "mail.labels", Write: write}, true case strings.HasPrefix(path, "/api/v1/mail/attachments/"), strings.Contains(path, "/attachments"): if write { return Requirement{Resource: "mail.attachments", Write: true}, true } return Requirement{Resource: "mail.attachments", Write: false}, true case strings.HasPrefix(path, "/api/v1/mail/messages"): if strings.HasSuffix(path, "/labels") || strings.HasSuffix(path, "/flags") { return Requirement{Resource: "mail.labels", Write: true}, true } if write { return Requirement{Resource: "mail.labels", Write: true}, true } return Requirement{Resource: "mail.messages", Write: false, ScopeHint: ScopeMailAccountQuery}, true case strings.HasPrefix(path, "/api/v1/mail/threads"): return Requirement{Resource: "mail.messages", Write: false}, true case strings.HasPrefix(path, "/api/v1/mail/drafts"): if write { return Requirement{Resource: "mail.send", Write: true}, true } return Requirement{Resource: "mail.messages", Write: false}, true default: return Requirement{}, false } } func richtextRequirement(method, path string) (Requirement, bool) { write := method != http.MethodGet && method != http.MethodHead switch { case strings.HasSuffix(path, "/save"), strings.HasSuffix(path, "/assets"), strings.HasSuffix(path, "/user-paragraph-styles"): return Requirement{Resource: "drive.files", Write: true}, true default: return Requirement{Resource: "drive.files", Write: write}, true } } func driveRequirement(method, path string) (Requirement, bool) { write := method != http.MethodGet && method != http.MethodHead switch { case strings.Contains(path, "/preview/"): return Requirement{Resource: "drive.thumbnails", Write: false, ScopeHint: ScopeDrivePathFromURL}, true case strings.Contains(path, "/download/"): return Requirement{Resource: "drive.download", Write: false, ScopeHint: ScopeDrivePathFromURL}, true case strings.Contains(path, "/shares"): return Requirement{Resource: "drive.share", Write: write}, true case strings.Contains(path, "/move"): return Requirement{Resource: "drive.move", Write: true}, true case strings.Contains(path, "/copy"): return Requirement{Resource: "drive.copy", Write: true}, true case strings.Contains(path, "/rename"): return Requirement{Resource: "drive.rename", Write: true}, true case strings.Contains(path, "/files/") || strings.Contains(path, "/folders/"): if write { return Requirement{Resource: "drive.upload", Write: true, ScopeHint: ScopeDrivePathFromURL}, true } return Requirement{ Resource: "drive.folders", Alternatives: []string{"drive.files"}, Write: false, ScopeHint: ScopeDrivePathFromURL, }, true case strings.Contains(path, "/search"), strings.Contains(path, "/recent"), strings.Contains(path, "/starred"), strings.Contains(path, "/shared"), strings.Contains(path, "/filter-corpus"), strings.Contains(path, "/quota"), strings.Contains(path, "/trash"): if write { return Requirement{Resource: "drive.upload", Write: true}, true } return Requirement{Resource: "drive.files", Write: false}, true default: return Requirement{}, false } } func searchRequirement(typesQuery string) (Requirement, bool) { types := parseSearchTypes(typesQuery) if len(types) == 0 { return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true } req := Requirement{Write: false, ScopeHint: ScopeMailAccountQuery} for _, t := range types { switch t { case "mail": req.Resource = "mail.search" case "contacts": req.Resource = "contacts.search" case "drive": req.Resource = "drive.files" default: continue } return req, true } return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true } func parseSearchTypes(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(strings.ToLower(part)) if part != "" { out = append(out, part) } } return out } func AllowsRequirement(auth *AuthContext, req Requirement) bool { if auth == nil { return true } if req.Resource == "automation.api_tokens" && !req.Write { return HasPermission(auth, req.Resource, true) } if HasPermission(auth, req.Resource, req.Write) { return true } for _, alt := range req.Alternatives { if HasPermission(auth, alt, req.Write) { return true } } return false } func SearchRequirements(typesQuery string) []Requirement { types := parseSearchTypes(typesQuery) if len(types) == 0 { return []Requirement{{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}} } reqs := make([]Requirement, 0, len(types)) for _, t := range types { switch t { case "mail": reqs = append(reqs, Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}) case "contacts": reqs = append(reqs, Requirement{Resource: "contacts.search", Write: false}) case "drive": reqs = append(reqs, Requirement{Resource: "drive.files", Write: false}) } } if len(reqs) == 0 { return []Requirement{{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}} } return reqs } func ExtractMailAccountID(path, queryAccountID string) string { if id := strings.TrimSpace(queryAccountID); id != "" { return id } parts := strings.Split(strings.Trim(path, "/"), "/") for i := 0; i < len(parts)-1; i++ { if parts[i] == "accounts" && i+1 < len(parts) { return parts[i+1] } } return "" } func ExtractDrivePathFromURL(fullPath string) string { markers := []string{ "/api/v1/drive/files/", "/api/v1/drive/download/", "/api/v1/drive/preview/", "/api/v1/drive/folders/", } for _, marker := range markers { if idx := strings.Index(fullPath, marker); idx >= 0 { rest := fullPath[idx+len(marker):] if rest == "" { return "/" } return NormalizeDriveScopePath("/" + rest) } } return "" }