wip: account details from az and the token
This commit is contained in:
1 parent
87d98d038d
commit
482e0805b7
6 files changed
+218
-17
No files matched your search
@@ -0,0 +1,160 @@
|
|||||||
|
package azure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
tenant := a.tenant
|
||||||
|
if a.tenantName != "" {
|
||||||
|
tenant = a.tenantName + " · " + a.tenant
|
||||||
|
}
|
||||||
|
add("tenant", 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, ", ")
|
||||||
|
}
|
||||||
@@ -19,7 +19,10 @@ import (
|
|||||||
// account is one az CLI identity in one tenant.
|
// account is one az CLI identity in one tenant.
|
||||||
type account struct {
|
type account struct {
|
||||||
user, tenant string
|
user, tenant string
|
||||||
|
userType string // "user" or "servicePrincipal"
|
||||||
tenantName string
|
tenantName string
|
||||||
|
cloud string // az's environment: AzureCloud, AzureChinaCloud, …
|
||||||
|
defaultSub string // az's default subscription's name, if this account's
|
||||||
anchor string // a subscription az maps to this account, to pin it
|
anchor string // a subscription az maps to this account, to pin it
|
||||||
anchorEnabled bool
|
anchorEnabled bool
|
||||||
isDefault bool
|
isDefault bool
|
||||||
@@ -52,12 +55,15 @@ func readAccounts(path string) ([]account, error) {
|
|||||||
var profile struct {
|
var profile struct {
|
||||||
Subscriptions []struct {
|
Subscriptions []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
IsDefault bool `json:"isDefault"`
|
IsDefault bool `json:"isDefault"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
TenantName string `json:"tenantDisplayName"`
|
TenantName string `json:"tenantDisplayName"`
|
||||||
|
Cloud string `json:"environmentName"`
|
||||||
User struct {
|
User struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
} `json:"subscriptions"`
|
} `json:"subscriptions"`
|
||||||
}
|
}
|
||||||
@@ -72,7 +78,7 @@ func readAccounts(path string) ([]account, error) {
|
|||||||
}
|
}
|
||||||
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
|
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
|
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName, userType: s.User.Type, cloud: s.Cloud})
|
||||||
i = len(out) - 1
|
i = len(out) - 1
|
||||||
}
|
}
|
||||||
a := &out[i]
|
a := &out[i]
|
||||||
@@ -82,7 +88,7 @@ func readAccounts(path string) ([]account, error) {
|
|||||||
enabled := s.State == "Enabled"
|
enabled := s.State == "Enabled"
|
||||||
switch {
|
switch {
|
||||||
case s.IsDefault:
|
case s.IsDefault:
|
||||||
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
|
a.anchor, a.anchorEnabled, a.isDefault, a.defaultSub = s.ID, enabled, true, s.Name
|
||||||
case a.isDefault:
|
case a.isDefault:
|
||||||
case a.anchor == "" || enabled && !a.anchorEnabled:
|
case a.anchor == "" || enabled && !a.anchorEnabled:
|
||||||
a.anchor, a.anchorEnabled = s.ID, enabled
|
a.anchor, a.anchorEnabled = s.ID, enabled
|
||||||
@@ -179,6 +185,7 @@ func NewAccounts(s Settings) ([]*Provider, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims}
|
||||||
if label != "" {
|
if label != "" {
|
||||||
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
||||||
p.label = label
|
p.label = label
|
||||||
|
|||||||
@@ -181,11 +181,11 @@ func TestTokenUser(t *testing.T) {
|
|||||||
`{"upn":"alice@contoso.com"}`: false,
|
`{"upn":"alice@contoso.com"}`: false,
|
||||||
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
|
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
|
||||||
} {
|
} {
|
||||||
if _, got := tokenUser(jwt(claims), users); got != want {
|
if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want {
|
||||||
t.Errorf("%s: %v", claims, got)
|
t.Errorf("%s: %v", claims, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, got := tokenUser("not-a-jwt", users); got {
|
if _, got := tokenUser(tokenClaims("not-a-jwt"), users); got {
|
||||||
t.Error("not a JWT")
|
t.Error("not a JWT")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,10 +36,11 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p
|
|||||||
type Provider struct {
|
type Provider struct {
|
||||||
id, label string // "" for the single-account defaults
|
id, label string // "" for the single-account defaults
|
||||||
|
|
||||||
cred azcore.TokenCredential
|
cred azcore.TokenCredential
|
||||||
opts options
|
opts options
|
||||||
subs *armsubscriptions.Client
|
account *accountDetails // nil for a service principal from AZURE_*
|
||||||
graph *armresourcegraph.Client
|
subs *armsubscriptions.Client
|
||||||
|
graph *armresourcegraph.Client
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
vaults map[string]*vaultClients // by vault URL
|
vaults map[string]*vaultClients // by vault URL
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ type cliCredential struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cache map[string]azcore.AccessToken // by resource and tenant argument
|
cache map[string]azcore.AccessToken // by resource and tenant argument
|
||||||
|
// last is the claims of the latest token in the account's own
|
||||||
|
// tenant, to describe the account (Provider.Account).
|
||||||
|
last map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// claims are the latest token's claims, nil before the first.
|
||||||
|
func (c *cliCredential) claims() map[string]any {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.last
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
|
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
|
||||||
@@ -110,29 +120,38 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return azcore.AccessToken{}, err
|
return azcore.AccessToken{}, err
|
||||||
}
|
}
|
||||||
if u, ok := tokenUser(tok.Token, others); ok {
|
claims := tokenClaims(tok.Token)
|
||||||
|
if u, ok := tokenUser(claims, others); ok {
|
||||||
return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user)
|
return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user)
|
||||||
}
|
}
|
||||||
|
if !otherTenant && claims != nil {
|
||||||
|
c.last = claims
|
||||||
|
}
|
||||||
c.cache[key] = tok
|
c.cache[key] = tok
|
||||||
return tok, nil
|
return tok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenUser reports which of users a token was issued to, if any, from
|
// tokenClaims reads a JWT's claims, without verifying it: they only
|
||||||
// its claims. The token isn't verified: this only catches az picking the
|
// describe the account and catch az picking the wrong one. nil if it
|
||||||
// wrong signed-in account.
|
// isn't a JWT.
|
||||||
func tokenUser(token string, users []string) (string, bool) {
|
func tokenClaims(token string) map[string]any {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) != 3 || len(users) == 0 {
|
if len(parts) != 3 {
|
||||||
return "", false
|
return nil
|
||||||
}
|
}
|
||||||
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return nil
|
||||||
}
|
}
|
||||||
var claims map[string]any
|
var claims map[string]any
|
||||||
if json.Unmarshal(b, &claims) != nil {
|
if json.Unmarshal(b, &claims) != nil {
|
||||||
return "", false
|
return nil
|
||||||
}
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenUser reports which of users a token's claims are for, if any.
|
||||||
|
func tokenUser(claims map[string]any, users []string) (string, bool) {
|
||||||
for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} {
|
for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} {
|
||||||
v, _ := claims[name].(string)
|
v, _ := claims[name].(string)
|
||||||
if i := strings.LastIndexByte(v, '#'); i >= 0 {
|
if i := strings.LastIndexByte(v, '#'); i >= 0 {
|
||||||
|
|||||||
@@ -184,3 +184,17 @@ type File struct {
|
|||||||
Line int // 1-based; 0 for the top
|
Line int // 1-based; 0 for the top
|
||||||
Sops string // the sops command to edit it with; empty for a plain file
|
Sops string // the sops command to edit it with; empty for a plain file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts is implemented by providers that can describe the identity
|
||||||
|
// they use, from what they have locally: for the Details pane on the
|
||||||
|
// provider's row. ok is false when there's nothing to say.
|
||||||
|
type Accounts interface {
|
||||||
|
Account() (Account, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account describes a signed-in identity.
|
||||||
|
type Account struct {
|
||||||
|
Fields []Attr
|
||||||
|
// Copy is what y copies on the provider's row, named CopyLabel.
|
||||||
|
Copy, CopyLabel string
|
||||||
|
}
|
||||||
Reference in new issue
Block a user