feat: a Fields pane for anything but a secret, first for Azure accounts: fields and groups to move through, filter and copy (#7)

This commit was merged in pull request #7.
This commit is contained in:
keb committed 2026-10-11 10:37:40 +02:00
1 parent f6c4f5f6f6
commit 71396f1e02
13 files changed
+404 -37

No files matched your search

+72 -5
View File
@@ -1,10 +1,15 @@
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"
)
@@ -40,11 +45,8 @@ func (p *Provider) Account() (provider.Account, bool) {
user += " (service principal)"
}
add("user", user)
tenant := a.tenant
if a.tenantName != "" {
tenant = a.tenantName + " · " + a.tenant
}
add("tenant", tenant)
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"
@@ -158,3 +160,68 @@ func directoryRoles(wids any) string {
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
}
+49 -2
View File
@@ -1,7 +1,17 @@
package azure
import (
"context"
"errors"
"io"
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -25,7 +35,7 @@ func TestAccount(t *testing.T) {
got[f.Label] = f.Value
}
want := map[string]string{
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso · t1",
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso", "tenant ID": "t1",
"object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform",
}
@@ -44,7 +54,7 @@ func TestAccount(t *testing.T) {
// Before any token: what az's list says.
claims = nil
a, _ = p.Account()
if len(a.Fields) != 3 || a.Copy != "" {
if len(a.Fields) != 4 || a.Copy != "" {
t.Errorf("without a token: %+v", a)
}
if _, ok := (&Provider{}).Account(); ok {
@@ -65,3 +75,40 @@ func TestSignInMethods(t *testing.T) {
}
}
}
type staticToken struct{}
func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil
}
type graphPages map[string]string // by URL
func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) {
body, ok := g[r.URL.String()]
if !ok || r.Header.Get("Authorization") != "Bearer tok" {
return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil
}
return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
}
func TestGroups(t *testing.T) {
next := "https://graph.microsoft.com/v1.0/next"
p := &Provider{
cred: staticToken{},
account: &accountDetails{account: account{user: "alice@contoso.com"}},
graphClient: &http.Client{Transport: graphPages{
graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"},
{"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`,
next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`,
}},
}
got, err := p.Groups(context.Background())
if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) {
t.Errorf("groups %q, %v", got, err)
}
p.account.cloud = "AzureChinaCloud"
if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) {
t.Errorf("other cloud: %v", err)
}
}
+7 -5
View File
@@ -9,6 +9,7 @@ import (
"crypto/x509"
"errors"
"fmt"
"net/http"
"regexp"
"slices"
"strings"
@@ -36,11 +37,12 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p
type Provider struct {
id, label string // "" for the single-account defaults
cred azcore.TokenCredential
opts options
account *accountDetails // nil for a service principal from AZURE_*
subs *armsubscriptions.Client
graph *armresourcegraph.Client
cred azcore.TokenCredential
opts options
account *accountDetails // nil for a service principal from AZURE_*
graphClient *http.Client // for Groups; nil for the default
subs *armsubscriptions.Client
graph *armresourcegraph.Client
mu sync.Mutex
vaults map[string]*vaultClients // by vault URL
+7
View File
@@ -198,3 +198,10 @@ type Account struct {
// Copy is what y copies on the provider's row, named CopyLabel.
Copy, CopyLabel string
}
// AccountGroups is implemented by providers that can list the groups
// their account is a member of, directly or through other groups. It
// goes over the network.
type AccountGroups interface {
Groups(ctx context.Context) ([]string, error)
}