feat: show who an Azure account signs in as, in Details on its row
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5355c65284
commit
2e941102a2
7 files changed
+151
-3
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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,15 @@ func readAccounts(path string) ([]account, error) {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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