Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
228 lines
7.2 KiB
Go
228 lines
7.2 KiB
Go
package azure
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
)
|
|
|
|
// accountDetails describes an az account from az's account list and the
|
|
// claims of the latest token lazysecrets got for it: nothing is fetched
|
|
// for it.
|
|
type accountDetails struct {
|
|
account
|
|
claims func() map[string]any
|
|
}
|
|
|
|
var _ provider.Accounts = (*Provider)(nil)
|
|
|
|
// Account describes the az account, once a token has been fetched for it
|
|
// (the user's name and object ID come from the token).
|
|
func (p *Provider) Account() (provider.Account, bool) {
|
|
if p.account == nil {
|
|
return provider.Account{}, false
|
|
}
|
|
a := p.account
|
|
claims := a.claims()
|
|
str := func(k string) string { s, _ := claims[k].(string); return s }
|
|
|
|
var out provider.Account
|
|
add := func(label, value string) {
|
|
if value != "" {
|
|
out.Fields = append(out.Fields, provider.Attr{Label: label, Value: value})
|
|
}
|
|
}
|
|
add("name", str("name"))
|
|
user := a.user
|
|
if a.userType == "servicePrincipal" {
|
|
user += " (service principal)"
|
|
}
|
|
add("user", user)
|
|
add("tenant", a.tenantName)
|
|
add("tenant ID", a.tenant)
|
|
if oid := str("oid"); oid != "" {
|
|
add("object ID", oid)
|
|
out.Copy, out.CopyLabel = oid, "object ID"
|
|
}
|
|
if claims != nil {
|
|
add("sign-in", signInMethods(claims["amr"]))
|
|
add("roles", directoryRoles(claims["wids"]))
|
|
}
|
|
subs := fmt.Sprint(a.subscriptions)
|
|
if a.defaultSub != "" {
|
|
subs += " · default " + a.defaultSub
|
|
}
|
|
add("subs", subs)
|
|
if a.cloud != "" && a.cloud != "AzureCloud" {
|
|
add("cloud", a.cloud)
|
|
}
|
|
return out, len(out.Fields) > 0
|
|
}
|
|
|
|
// signInMethods says how the session was signed in to, from the amr
|
|
// claim: with MFA or not, and by what.
|
|
func signInMethods(amr any) string {
|
|
list, _ := amr.([]any)
|
|
var methods []string
|
|
mfa := false
|
|
for _, m := range list {
|
|
s, _ := m.(string)
|
|
switch s {
|
|
case "mfa", "ngcmfa":
|
|
mfa = true
|
|
case "pwd":
|
|
methods = append(methods, "password")
|
|
case "rsa":
|
|
methods = append(methods, "Windows Hello or certificate")
|
|
case "fido":
|
|
methods = append(methods, "security key")
|
|
case "wia":
|
|
methods = append(methods, "Windows sign-in")
|
|
case "otp", "sms":
|
|
methods = append(methods, "one-time code")
|
|
}
|
|
}
|
|
if len(list) == 0 {
|
|
return ""
|
|
}
|
|
how := strings.Join(methods, ", ")
|
|
switch {
|
|
case mfa && how != "":
|
|
return "MFA · " + how
|
|
case mfa:
|
|
return "MFA"
|
|
case how != "":
|
|
return "no MFA · " + how
|
|
}
|
|
return "no MFA"
|
|
}
|
|
|
|
// builtinRoles names Entra ID's built-in directory roles by template ID,
|
|
// the IDs a token's wids claim lists.
|
|
var builtinRoles = map[string]string{
|
|
"62e90394-69f5-4237-9190-012177145e10": "Global Administrator",
|
|
"f2ef992c-3afb-46b9-b7cf-a126ee74c451": "Global Reader",
|
|
"e8611ab8-c189-46e8-94e1-60213ab1f814": "Privileged Role Administrator",
|
|
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13": "Privileged Authentication Administrator",
|
|
"194ae4cb-b126-40b2-bd5b-6091b380977d": "Security Administrator",
|
|
"5d6b6bb7-de71-4623-b4af-96380a352509": "Security Reader",
|
|
"5f2222b1-57c3-48ba-8ad5-d4759f1fde6f": "Security Operator",
|
|
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3": "Application Administrator",
|
|
"158c047a-c907-4556-b7ef-446551a6b5f7": "Cloud Application Administrator",
|
|
"cf1c38e5-3621-4004-a7cb-879624dced7c": "Application Developer",
|
|
"fe930be7-5e62-47db-91af-98c3a49a38b1": "User Administrator",
|
|
"fdd7a751-b60b-444a-984c-02652fe8fa1c": "Groups Administrator",
|
|
"729827e3-9c14-49f7-bb1b-9608f156bbb8": "Helpdesk Administrator",
|
|
"966707d0-3269-4727-9be2-8c3a10f19b9d": "Password Administrator",
|
|
"c4e39bd9-1100-46d3-8c65-fb160da0071f": "Authentication Administrator",
|
|
"0526716b-113d-4c15-b2c8-68e3c22b9f80": "Authentication Policy Administrator",
|
|
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2": "Hybrid Identity Administrator",
|
|
"b0f54661-2d74-4c50-afa3-1ec803f12efe": "Billing Administrator",
|
|
"4d6ac14f-3453-41d0-bef9-a3e0c569773a": "License Administrator",
|
|
"17315797-102d-40b4-93e0-432062caca18": "Compliance Administrator",
|
|
"29232cdf-9323-42fd-ade2-1d097af3e4de": "Exchange Administrator",
|
|
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c": "SharePoint Administrator",
|
|
"69091246-20e8-4a56-aa4d-066075b2a7a8": "Teams Administrator",
|
|
"3a2c62db-5318-420d-8d74-23affee5d9d5": "Intune Administrator",
|
|
"11648597-926c-4cf3-9c36-bcebb0ba8dcc": "Power Platform Administrator",
|
|
"e3973bdf-4987-49ae-837a-ba8e231c7286": "Azure DevOps Administrator",
|
|
"9360feb5-f418-4baa-8175-e2a00bac4301": "Directory Writers",
|
|
"88d8e3e3-8f55-4a1e-953a-9b9898b8876b": "Directory Readers",
|
|
"95e79109-95c0-4d8e-aee3-d01accf2d47b": "Guest Inviter",
|
|
}
|
|
|
|
// memberRole is in every member's token: not worth listing.
|
|
const memberRole = "b79fbf4d-3ef9-4689-8143-76b194e85509"
|
|
|
|
// directoryRoles names the roles in a wids claim: built-in ones by name,
|
|
// others by their ID's first part.
|
|
func directoryRoles(wids any) string {
|
|
list, _ := wids.([]any)
|
|
var names []string
|
|
for _, w := range list {
|
|
id, _ := w.(string)
|
|
switch name, ok := builtinRoles[strings.ToLower(id)]; {
|
|
case id == "" || id == memberRole:
|
|
case ok:
|
|
names = append(names, name)
|
|
default:
|
|
short, _, _ := strings.Cut(id, "-")
|
|
names = append(names, "role "+short)
|
|
}
|
|
}
|
|
slices.Sort(names)
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// graphGroups lists the signed-in user's groups, nested ones too, from
|
|
// Microsoft Graph with az's token for it.
|
|
const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999"
|
|
|
|
var _ provider.AccountGroups = (*Provider)(nil)
|
|
|
|
// Groups lists the groups the account is a member of, by name.
|
|
func (p *Provider) Groups(ctx context.Context) ([]string, error) {
|
|
if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") {
|
|
return nil, provider.ErrUnsupported
|
|
}
|
|
tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var names []string
|
|
for next := graphGroups; next != ""; {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok.Token)
|
|
resp, err := p.http().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var page struct {
|
|
Value []struct {
|
|
Type string `json:"@odata.type"`
|
|
Name string `json:"displayName"`
|
|
} `json:"value"`
|
|
Next string `json:"@odata.nextLink"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
err = json.NewDecoder(resp.Body).Decode(&page)
|
|
resp.Body.Close()
|
|
switch {
|
|
case err != nil:
|
|
return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err)
|
|
case page.Error != nil:
|
|
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message)
|
|
case resp.StatusCode != http.StatusOK:
|
|
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status)
|
|
}
|
|
for _, v := range page.Value {
|
|
if v.Type == "#microsoft.graph.group" && v.Name != "" {
|
|
names = append(names, v.Name)
|
|
}
|
|
}
|
|
next = page.Next
|
|
}
|
|
slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
|
|
return names, nil
|
|
}
|
|
|
|
// http is the client for Microsoft Graph; tests replace it.
|
|
func (p *Provider) http() *http.Client {
|
|
if p.graphClient != nil {
|
|
return p.graphClient
|
|
}
|
|
return http.DefaultClient
|
|
}
|