feat: show who an Azure account signs in as, in Details on its row (#5)
On an Azure account's group row, the Details pane now describes the account: | field | from | |---|---| | name | token `name` | | user (and "service principal") | az's account list | | tenant: name · ID | az's account list (the name is borrowed from another entry in the same tenant when az left it out) | | object ID | token `oid`; `y` on the row copies it | | sign-in: MFA or not, and how | token `amr` | | directory roles | token `wids`: built-in roles by name, others as `role 1a2b3c4d`, the default member role left out | | subs: count · default | az's account list | | cloud | az's account list, when not AzureCloud | Nothing is fetched for it: the claims are the ones from the token lazysecrets already gets for the account's own tenant, read without verification. The token itself is never stored or shown. Before the first token, only the az-list fields show. A failing account shows its sign-in problem there instead (from #4). New optional `provider.Accounts` interface; `azure.Provider` implements it. Tests: the fields from claims and az's list, sign-in methods, the UI showing it on the group row only, and `y` copying the object ID. Checked against a real signed-in account (name, MFA · password, default subscription) and an expired one (az-list fields only, since there's no token). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com> Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
1 parent
a82f6bff27
commit
52b6257dd5
12 files changed
+369
-20
No files matched your search
+11
-2
@@ -13,8 +13,17 @@ is listed under each, since their permissions can differ.
|
|||||||
A service principal is used instead when `AZURE_CLIENT_ID`,
|
A service principal is used instead when `AZURE_CLIENT_ID`,
|
||||||
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
|
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
|
||||||
|
|
||||||
If your az login expires mid-session, the status line says so: press `L` to
|
On an account's group row, the Details pane shows who it signs in as: name,
|
||||||
run `az login` without leaving lazysecrets, and everything reloads.
|
user, tenant, object ID, whether the session used MFA, directory roles
|
||||||
|
(built-in ones by name) and its subscriptions. It's read from az's account
|
||||||
|
list and the token lazysecrets already has, so nothing is fetched for it.
|
||||||
|
`y` there copies the object ID, the one access policies and role
|
||||||
|
assignments ask for.
|
||||||
|
|
||||||
|
If an account's az login has expired, the status line says so once, and the
|
||||||
|
Details pane on that account's rows says it until you sign in: press `L`
|
||||||
|
there to run `az login` for it without leaving lazysecrets, and everything
|
||||||
|
reloads.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ match case exactly.
|
|||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s |
|
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID |
|
||||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||||
|
|||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package azure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccount(t *testing.T) {
|
||||||
|
claims := map[string]any{
|
||||||
|
"name": "Alice Example", "oid": "11111111-2222-3333-4444-555555555555",
|
||||||
|
"amr": []any{"pwd", "mfa"},
|
||||||
|
"wids": []any{"62e90394-69f5-4237-9190-012177145e10", memberRole, "aaaaaaaa-0000-0000-0000-000000000000"},
|
||||||
|
}
|
||||||
|
p := &Provider{account: &accountDetails{
|
||||||
|
account: account{user: "alice@contoso.com", tenant: "t1", tenantName: "Contoso", subscriptions: 3, defaultSub: "platform"},
|
||||||
|
claims: func() map[string]any { return claims },
|
||||||
|
}}
|
||||||
|
a, ok := p.Account()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no account")
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, f := range a.Fields {
|
||||||
|
got[f.Label] = f.Value
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso · t1",
|
||||||
|
"object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
|
||||||
|
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform",
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("%s = %q, want %q", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("fields %v", got)
|
||||||
|
}
|
||||||
|
if a.Copy != claims["oid"] || a.CopyLabel != "object ID" {
|
||||||
|
t.Errorf("copy %q %q", a.Copy, a.CopyLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before any token: what az's list says.
|
||||||
|
claims = nil
|
||||||
|
a, _ = p.Account()
|
||||||
|
if len(a.Fields) != 3 || a.Copy != "" {
|
||||||
|
t.Errorf("without a token: %+v", a)
|
||||||
|
}
|
||||||
|
if _, ok := (&Provider{}).Account(); ok {
|
||||||
|
t.Error("a service principal from AZURE_* has no account details")
|
||||||
|
}
|
||||||
|
var _ provider.Accounts = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInMethods(t *testing.T) {
|
||||||
|
for want, amr := range map[string][]any{
|
||||||
|
"MFA · password": {"pwd", "mfa"},
|
||||||
|
"MFA · Windows Hello or certificate": {"rsa", "ngcmfa"},
|
||||||
|
"no MFA · password": {"pwd"},
|
||||||
|
"": nil,
|
||||||
|
} {
|
||||||
|
if got := signInMethods(amr); got != want {
|
||||||
|
t.Errorf("%v: %q, want %q", amr, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 +88,21 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// az leaves the tenant's name out of some entries: take it from
|
||||||
|
// another in the same tenant.
|
||||||
|
for i := range out {
|
||||||
|
for _, b := range out {
|
||||||
|
if out[i].tenantName == "" && b.tenant == out[i].tenant {
|
||||||
|
out[i].tenantName = b.tenantName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
slices.SortStableFunc(out, func(a, b account) int {
|
slices.SortStableFunc(out, func(a, b account) int {
|
||||||
if a.isDefault != b.isDefault {
|
if a.isDefault != b.isDefault {
|
||||||
if a.isDefault {
|
if a.isDefault {
|
||||||
@@ -179,6 +194,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
|
||||||
|
}
|
||||||
@@ -723,6 +723,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.askHandlerSetup()
|
m.askHandlerSetup()
|
||||||
m.keys.Login.SetEnabled(m.loginFor() != nil)
|
m.keys.Login.SetEnabled(m.loginFor() != nil)
|
||||||
m.detail.SetMessage(m.selectedProblem())
|
m.detail.SetMessage(m.selectedProblem())
|
||||||
|
acct, _ := m.selectedAccount()
|
||||||
|
m.detail.SetAccount(acct.Fields)
|
||||||
if m.anyLoading() && !m.spinning {
|
if m.anyLoading() && !m.spinning {
|
||||||
m.spinning = true
|
m.spinning = true
|
||||||
cmds = append(cmds, m.spin.Tick)
|
cmds = append(cmds, m.spin.Tick)
|
||||||
@@ -1453,6 +1455,9 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
|||||||
func (m *Model) copyValue() tea.Cmd {
|
func (m *Model) copyValue() tea.Cmd {
|
||||||
o, ok := m.detail.Secret()
|
o, ok := m.detail.Secret()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
|
||||||
|
return m.copyText(a.CopyLabel, a.Copy, false)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if v, ok := m.detail.Value(); ok {
|
if v, ok := m.detail.Value(); ok {
|
||||||
@@ -1674,3 +1679,20 @@ func (m Model) loginFor() *provider.AuthError {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectedAccount describes the account of the selected provider row, if
|
||||||
|
// its provider can.
|
||||||
|
func (m Model) selectedAccount() (provider.Account, bool) {
|
||||||
|
r, ok := m.sources.Selected()
|
||||||
|
if !ok || r.kind != rowGroup {
|
||||||
|
return provider.Account{}, false
|
||||||
|
}
|
||||||
|
if res := m.sourceResults[r.provider]; res.err != nil {
|
||||||
|
return provider.Account{}, false // the problem shows instead
|
||||||
|
}
|
||||||
|
a, ok := m.byID[r.provider].(provider.Accounts)
|
||||||
|
if !ok {
|
||||||
|
return provider.Account{}, false
|
||||||
|
}
|
||||||
|
return a.Account()
|
||||||
|
}
|
||||||
@@ -1069,3 +1069,33 @@ func lastLines(scr string, n int) string {
|
|||||||
lines := strings.Split(strings.TrimRight(scr, "\n"), "\n")
|
lines := strings.Split(strings.TrimRight(scr, "\n"), "\n")
|
||||||
return strings.Join(lines[max(0, len(lines)-n):], "\n")
|
return strings.Join(lines[max(0, len(lines)-n):], "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// described is an account that can say who it is.
|
||||||
|
type described struct{ provider.Provider }
|
||||||
|
|
||||||
|
func (described) Account() (provider.Account, bool) {
|
||||||
|
return provider.Account{
|
||||||
|
Fields: []provider.Attr{{Label: "name", Value: "Alice Example"}, {Label: "object ID", Value: "oid-123"}},
|
||||||
|
Copy: "oid-123", CopyLabel: "object ID",
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountInDetails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := newDriver(t, described{fake.Azure(0)})
|
||||||
|
d.keys("g") // the group's row
|
||||||
|
if r, _ := d.m.sources.Selected(); r.kind != rowGroup {
|
||||||
|
t.Fatalf("not on the group: %+v", r)
|
||||||
|
}
|
||||||
|
if scr := d.screen(); !strings.Contains(scr, "Alice Example") || !strings.Contains(scr, "oid-123") {
|
||||||
|
t.Errorf("account not in Details:\n%s", scr)
|
||||||
|
}
|
||||||
|
d.keys("y")
|
||||||
|
if got := d.clip().content; got != "oid-123" {
|
||||||
|
t.Errorf("y copied %q", got)
|
||||||
|
}
|
||||||
|
d.keys("j")
|
||||||
|
if strings.Contains(d.screen(), "Alice Example") {
|
||||||
|
t.Errorf("still shown on a subscription:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ type Detail struct {
|
|||||||
versions []provider.Object // newest first; nil while unknown
|
versions []provider.Object // newest first; nil while unknown
|
||||||
note string // the user's note on what's shown
|
note string // the user's note on what's shown
|
||||||
message string // shown instead of the hint when nothing is selected: an error
|
message string // shown instead of the hint when nothing is selected: an error
|
||||||
|
account []provider.Attr // shown instead of the hint on a provider's row: who it signs in as
|
||||||
|
|
||||||
offset int
|
offset int
|
||||||
w, h int
|
w, h int
|
||||||
@@ -76,6 +77,10 @@ func (d *Detail) Clear() {
|
|||||||
// selected, such as why an account has nothing to show; "" for the hint.
|
// selected, such as why an account has nothing to show; "" for the hint.
|
||||||
func (d *Detail) SetMessage(s string) { d.message = s }
|
func (d *Detail) SetMessage(s string) { d.message = s }
|
||||||
|
|
||||||
|
// SetAccount sets the account shown on a provider's row when nothing is
|
||||||
|
// selected; nil for the hint.
|
||||||
|
func (d *Detail) SetAccount(fields []provider.Attr) { d.account = fields }
|
||||||
|
|
||||||
// SetDescribing marks extra metadata as being fetched.
|
// SetDescribing marks extra metadata as being fetched.
|
||||||
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
|
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
|
||||||
|
|
||||||
@@ -191,6 +196,12 @@ func (d Detail) lines(st styles.Styles) []string {
|
|||||||
muted := st.Muted.Render
|
muted := st.Muted.Render
|
||||||
|
|
||||||
if d.container == nil {
|
if d.container == nil {
|
||||||
|
if d.message == "" && len(d.account) > 0 {
|
||||||
|
for _, a := range d.account {
|
||||||
|
field(a.Label, a.Value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
if d.message != "" {
|
if d.message != "" {
|
||||||
for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") {
|
for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") {
|
||||||
out = append(out, " "+st.Error.Render(l))
|
out = append(out, " "+st.Error.Render(l))
|
||||||
|
|||||||
Reference in new issue
Block a user