Hello world 👋
Release / Build and publish (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-10 23:58:25 +02:00
commit 6fab41703e
111 files changed
+22988

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
package azure
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// account is one az CLI identity in one tenant.
type account struct {
user, tenant string
tenantName string
anchor string // a subscription az maps to this account, to pin it
anchorEnabled bool
isDefault bool
subscriptions int // how many subscriptions az maps to it
}
// azProfilePath is az's account list. It holds no tokens.
func azProfilePath() (string, error) {
dir := os.Getenv("AZURE_CONFIG_DIR")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir = filepath.Join(home, ".azure")
}
return filepath.Join(dir, "azureProfile.json"), nil
}
// readAccounts lists the accounts signed in to az, default first.
func readAccounts(path string) ([]account, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) // az writes a BOM
var profile struct {
Subscriptions []struct {
ID string `json:"id"`
State string `json:"state"`
IsDefault bool `json:"isDefault"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantDisplayName"`
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"subscriptions"`
}
if err := json.Unmarshal(b, &profile); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
var out []account
for _, s := range profile.Subscriptions {
if s.User.Name == "" || s.TenantID == "" {
continue
}
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
if i < 0 {
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
i = len(out) - 1
}
a := &out[i]
a.subscriptions++
// Anchor on az's default subscription if it is this account's,
// otherwise on an enabled one.
enabled := s.State == "Enabled"
switch {
case s.IsDefault:
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
case a.isDefault:
case a.anchor == "" || enabled && !a.anchorEnabled:
a.anchor, a.anchorEnabled = s.ID, enabled
}
}
slices.SortStableFunc(out, func(a, b account) int {
if a.isDefault != b.isDefault {
if a.isDefault {
return -1
}
return 1
}
return cmp.Compare(a.user, b.user)
})
return out, nil
}
// accountLabels names accounts as briefly as stays unambiguous: the user
// name before the @, the full user if another user shares that, plus the
// tenant if one user is signed in to several.
func accountLabels(accounts []account) []string {
short := func(u string) string { s, _, _ := strings.Cut(u, "@"); return s }
labels := make([]string, len(accounts))
for i, a := range accounts {
l, tenants := short(a.user), 0
for _, b := range accounts {
if b.user != a.user && short(b.user) == short(a.user) {
l = a.user
}
if b.user == a.user {
tenants++
}
}
if tenants > 1 {
l += " · " + cmp.Or(a.tenantName, a.tenant)
}
labels[i] = l
}
return labels
}
// NewAccounts returns one provider per account signed in to az, the default
// first, or a single provider if AZURE_* configures a service principal or
// az has no accounts yet. Pinned vaults go to the first.
func NewAccounts(s Settings) ([]*Provider, error) {
if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_TENANT_ID") != "" {
cred, err := azidentity.NewEnvironmentCredential(nil)
if err != nil {
return nil, err
}
p, err := newWithOptions(authCredential{cred: cred}, options{Settings: s})
if err != nil {
return nil, err
}
return []*Provider{p}, nil
}
path, err := azProfilePath()
if err != nil {
return nil, err
}
accounts, err := readAccounts(path)
if err != nil {
return nil, err
}
if len(accounts) == 0 {
// Not signed in yet: the first call fails with an AuthError and
// the UI offers az login.
cred := authCredential{cred: newCLICredential("", ""), login: []string{"az", "login"}}
p, err := newWithOptions(cred, options{Settings: s})
if err != nil {
return nil, err
}
return []*Provider{p}, nil
}
labels := accountLabels(accounts)
var out []*Provider
for i, a := range accounts {
settings := s
if i > 0 {
settings.Vaults = nil
}
label := ""
if len(accounts) > 1 {
label = string(provider.KindAzure) + " · " + labels[i]
}
cred := authCredential{
cred: newCLICredential(a.anchor, a.tenant),
login: []string{"az", "login", "--tenant", a.tenant},
account: label, // so "sign-in required" says which account
}
p, err := newWithOptions(cred, options{Settings: settings})
if err != nil {
return nil, err
}
if label != "" {
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
p.label = label
}
out = append(out, p)
}
return out, nil
}
+121
View File
@@ -0,0 +1,121 @@
package azure
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)
const profileJSON = "\xef\xbb\xbf" + `{"installationId": "x", "subscriptions": [
{"id": "s-admin-1", "name": "platform", "state": "Enabled", "isDefault": false, "tenantId": "t1", "tenantDisplayName": "Contoso",
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
{"id": "s-admin-2", "name": "old", "state": "Disabled", "isDefault": false, "tenantId": "t1",
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
{"id": "s-alice-1", "name": "app-dev", "state": "Enabled", "isDefault": false, "tenantId": "t1",
"user": {"name": "alice@contoso.com", "type": "user"}},
{"id": "s-alice-2", "name": "app-prod", "state": "Enabled", "isDefault": true, "tenantId": "t1",
"user": {"name": "alice@contoso.com", "type": "user"}}
]}`
func TestReadAccounts(t *testing.T) {
path := filepath.Join(t.TempDir(), "azureProfile.json")
if err := os.WriteFile(path, []byte(profileJSON), 0o600); err != nil {
t.Fatal(err)
}
got, err := readAccounts(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("accounts = %+v", got)
}
// Default account first, anchored on the default subscription.
if got[0].user != "alice@contoso.com" || got[0].anchor != "s-alice-2" || !got[0].isDefault {
t.Errorf("default account = %+v", got[0])
}
// Anchored on an enabled subscription, not the disabled one.
if got[1].user != "admin.alice@contoso.onmicrosoft.com" || got[1].anchor != "s-admin-1" || got[1].subscriptions != 2 {
t.Errorf("second account = %+v", got[1])
}
if accts, err := readAccounts(filepath.Join(t.TempDir(), "missing.json")); err != nil || accts != nil {
t.Errorf("missing profile: %v, %v", accts, err)
}
}
func TestAccountLabels(t *testing.T) {
got := accountLabels([]account{
{user: "alice@contoso.com", tenant: "t1"},
{user: "admin@a.example", tenant: "t1"},
{user: "admin@b.example", tenant: "t1"},
{user: "guest@x.example", tenant: "t1", tenantName: "Home"},
{user: "guest@x.example", tenant: "t2", tenantName: "Partner"},
})
want := []string{"alice", "admin@a.example", "admin@b.example", "guest · Home", "guest · Partner"}
if !reflect.DeepEqual(got, want) {
t.Errorf("labels = %q\nwant %q", got, want)
}
}
func TestCLICredentialPinsAccount(t *testing.T) {
var calls [][]string
c := newCLICredential("s-alice-2", "t1")
c.run = func(_ context.Context, args ...string) ([]byte, error) {
calls = append(calls, args)
exp := time.Now().Add(time.Hour).Unix()
return []byte(fmt.Sprintf(`{"accessToken": "tok", "expires_on": %d}`, exp)), nil
}
ctx := context.Background()
vault := policy.TokenRequestOptions{Scopes: []string{"https://vault.azure.net/.default"}}
// Same tenant (Key Vault's challenge names it): pin with --subscription only.
vault.TenantID = "t1"
if tok, err := c.GetToken(ctx, vault); err != nil || tok.Token != "tok" {
t.Fatalf("token: %v, %v", tok, err)
}
if got := strings.Join(calls[0], " "); !strings.Contains(got, "--subscription s-alice-2") || strings.Contains(got, "--tenant") {
t.Errorf("same tenant args: %s", got)
}
// Cached: no second az call.
if _, err := c.GetToken(ctx, vault); err != nil || len(calls) != 1 {
t.Errorf("token not cached: %d calls", len(calls))
}
// Another tenant: --tenant only, since az refuses both.
vault.TenantID = "t2"
if _, err := c.GetToken(ctx, vault); err != nil {
t.Fatal(err)
}
if got := strings.Join(calls[1], " "); !strings.Contains(got, "--tenant t2") || strings.Contains(got, "--subscription") {
t.Errorf("other tenant args: %s", got)
}
}
func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
n := 0
c := newCLICredential("", "")
c.run = func(context.Context, ...string) ([]byte, error) {
n++
if n == 2 {
return nil, errors.New("AADSTS700082: The refresh token has expired. Please run 'az login'")
}
// Expires inside the refresh margin, so the next call renews.
return []byte(`{"accessToken": "tok", "expiresOn": "` + time.Now().Add(time.Minute).Format("2006-01-02 15:04:05.000000") + `"}`), nil
}
opts := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
if _, err := c.GetToken(context.Background(), opts); err != nil {
t.Fatal(err)
}
if _, err := c.GetToken(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "AADSTS700082") {
t.Errorf("err = %v", err)
}
}
+27
View File
@@ -0,0 +1,27 @@
package azure
import (
"context"
"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"
)
// authCredential turns every token failure into a *provider.AuthError, so
// the UI can tell "log in again" apart from other failures however deep in
// the SDK the token was requested.
type authCredential struct {
cred azcore.TokenCredential
login []string
account string // label when there are several accounts
}
func (c authCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
tok, err := c.cred.GetToken(ctx, opts)
if err != nil && ctx.Err() == nil {
err = &provider.AuthError{Kind: provider.KindAzure, Account: c.account, Login: c.login, Err: err}
}
return tok, err
}
+524
View File
@@ -0,0 +1,524 @@
// Package azure is the Azure Key Vault provider. Sources are subscriptions,
// containers are vaults (found via Resource Graph), and objects are secrets,
// keys and certificates.
package azure
import (
"cmp"
"context"
"crypto/x509"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
// Provider serves one Azure identity: an az CLI account or a service
// principal. Creating it doesn't contact Azure; authentication problems
// surface as *provider.AuthError from the first call.
type Provider struct {
id, label string // "" for the single-account defaults
cred azcore.TokenCredential
opts options
subs *armsubscriptions.Client
graph *armresourcegraph.Client
mu sync.Mutex
vaults map[string]*vaultClients // by vault URL
tenants map[string]string // tenant ID by subscription ID, for portal links
}
type vaultClients struct {
secrets *azsecrets.Client
keys *azkeys.Client
certs *azcertificates.Client
}
var _ provider.Provider = (*Provider)(nil)
// Settings come from the config file.
type Settings struct {
// HideSubscriptions lists subscriptions to leave out, by name or ID.
HideSubscriptions []string
// Vaults are listed under a "Pinned vaults" source.
Vaults []string
}
// pinnedID identifies the source holding vaults from the config file.
const pinnedID = "pinned-vaults"
// options lets tests point the SDK clients at a local server.
type options struct {
Settings
arm *arm.ClientOptions
client azcore.ClientOptions
skipChallengeValidation bool
}
func newWithOptions(cred azcore.TokenCredential, opts options) (*Provider, error) {
subs, err := armsubscriptions.NewClient(cred, opts.arm)
if err != nil {
return nil, err
}
graph, err := armresourcegraph.NewClient(cred, opts.arm)
if err != nil {
return nil, err
}
return &Provider{
cred: cred, opts: opts, subs: subs, graph: graph,
vaults: map[string]*vaultClients{}, tenants: map[string]string{},
}, nil
}
func (p *Provider) Kind() provider.Kind { return provider.KindAzure }
func (p *Provider) ID() string { return cmp.Or(p.id, string(provider.KindAzure)) }
func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindAzure)) }
// Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
var out []provider.Source
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
continue
}
id, tenant := deref(s.SubscriptionID), deref(s.TenantID)
if config.Hidden(p.opts.HideSubscriptions, deref(s.DisplayName), id) {
continue
}
p.mu.Lock()
p.tenants[id] = tenant
p.mu.Unlock()
out = append(out, provider.Source{
Kind: provider.KindAzure,
ID: id,
Name: deref(s.DisplayName),
Detail: "subscription " + id,
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
})
}
}
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
if len(p.opts.Vaults) > 0 {
out = append(out, provider.Source{
Kind: provider.KindAzure, ID: pinnedID, Name: "Pinned vaults", Detail: "from the config file",
})
}
return out, nil
}
// pinnedVaults lists the vaults from the config file. Without a resource ID
// they have no portal link.
func (p *Provider) pinnedVaults(src provider.Source) []provider.Container {
out := make([]provider.Container, 0, len(p.opts.Vaults))
for _, u := range p.opts.Vaults {
if !strings.HasSuffix(u, "/") {
u += "/"
}
name, _, _ := strings.Cut(strings.TrimPrefix(u, "https://"), ".")
out = append(out, provider.Container{Source: src, ID: u, Name: name, Detail: u, Types: objectTypes})
}
return out
}
// Containers finds the subscription's vaults with Resource Graph, falling
// back to the Key Vault management API if the query fails.
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
if src.ID == pinnedID {
return p.pinnedVaults(src), nil
}
vaults, err := p.graphVaults(ctx, src)
if err != nil {
var auth *provider.AuthError
if errors.As(err, &auth) || ctx.Err() != nil {
return nil, err
}
vaults, err = p.armVaults(ctx, src)
if err != nil {
return nil, err
}
}
tenant := p.tenant(src.ID)
for i := range vaults {
vaults[i].URL = PortalResourceURL(tenant, vaults[i].ID, "overview")
}
slices.SortFunc(vaults, func(a, b provider.Container) int {
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return vaults, nil
}
func (p *Provider) tenant(subscriptionID string) string {
p.mu.Lock()
defer p.mu.Unlock()
return p.tenants[subscriptionID]
}
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri)`
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
var out []provider.Container
req := armresourcegraph.QueryRequest{
Query: to.Ptr(vaultQuery),
Subscriptions: []*string{to.Ptr(src.ID)},
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
}
for {
resp, err := p.graph.Resources(ctx, req, nil)
if err != nil {
return nil, cleanError(err)
}
rows, ok := resp.Data.([]any)
if !ok {
return nil, fmt.Errorf("resource graph: unexpected result %T", resp.Data)
}
for _, r := range rows {
if c, ok := graphVault(src, r); ok {
out = append(out, c)
}
}
if resp.SkipToken == nil || *resp.SkipToken == "" {
return out, nil
}
req.Options.SkipToken = resp.SkipToken
}
}
func graphVault(src provider.Source, row any) (provider.Container, bool) {
m, ok := row.(map[string]any)
if !ok {
return provider.Container{}, false
}
id, _ := m["id"].(string)
name, _ := m["name"].(string)
uri, _ := m["uri"].(string)
if id == "" || name == "" || uri == "" {
return provider.Container{}, false
}
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
}
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
client, err := armkeyvault.NewVaultsClient(src.ID, p.cred, p.opts.arm)
if err != nil {
return nil, err
}
var out []provider.Container
pager := client.NewListBySubscriptionPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes,
})
}
}
return out, nil
}
func (p *Provider) clients(vaultURL string) (*vaultClients, error) {
p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.vaults[vaultURL]; ok {
return c, nil
}
skip := p.opts.skipChallengeValidation
s, err := azsecrets.NewClient(vaultURL, p.cred, &azsecrets.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
k, err := azkeys.NewClient(vaultURL, p.cred, &azkeys.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
c, err := azcertificates.NewClient(vaultURL, p.cred, &azcertificates.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
vc := &vaultClients{secrets: s, keys: k, certs: c}
p.vaults[vaultURL] = vc
return vc, nil
}
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
vc, err := p.clients(c.Detail)
if err != nil {
return nil, err
}
var out []provider.Object
switch t {
case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates.
if s.ID == nil || deref(s.Managed) {
continue
}
out = append(out, secretObject(c, s))
}
}
case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID == nil || deref(k.Managed) {
continue
}
out = append(out, keyObject(c, k))
}
}
case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID == nil {
continue
}
out = append(out, certObject(c, cp))
}
}
default:
return nil, provider.ErrUnsupported
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
// Describe fetches key and certificate details. Secrets are returned as-is:
// the only way to get more is GetSecret, which returns the value.
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return o, err
}
switch o.Type {
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return o, cleanError(err)
}
if resp.Key != nil {
if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
}
o.Attrs = keyAttrs(resp.Key)
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return o, cleanError(err)
}
if resp.ID != nil {
o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
}
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType)
}
if cert, err := x509.ParseCertificate(resp.CER); err == nil {
o.Attrs = certAttrs(cert, resp.X509Thumbprint)
}
}
return o, nil
}
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return nil, err
}
var out []provider.Object
switch o.Type {
case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
if s.ID != nil {
out = append(out, secretObject(o.Container, s))
}
}
}
case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil {
out = append(out, keyObject(o.Container, k))
}
}
}
case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
out = append(out, certObject(o.Container, cp))
}
}
}
default:
return nil, provider.ErrUnsupported
}
for i := range out {
out[i].Reference = versionedReference(out[i])
}
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
return out, nil
}
// versionedReference pins an object's reference to its version, e.g.
// @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/name/v1).
func versionedReference(o provider.Object) string {
if o.Version == "" {
return o.Reference
}
if ref, ok := strings.CutSuffix(o.Reference, ")"); ok && o.Type == provider.TypeSecret {
return ref + "/" + o.Version + ")"
}
return o.Reference + "/" + o.Version
}
// Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
}
var _ provider.Locator = (*Provider)(nil)
// validHost guards the Resource Graph query against odd input.
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
// Locate finds the subscription of the vault with the given host: a pinned
// vault, or one Resource Graph query across the account's subscriptions.
// Vaults in hidden subscriptions aren't found.
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
host = strings.ToLower(host)
if !validHost.MatchString(host) {
return "", false, fmt.Errorf("not a vault host: %q", host)
}
url := "https://" + host + "/"
for _, v := range p.opts.Vaults {
if strings.EqualFold(strings.TrimSuffix(v, "/")+"/", url) {
return pinnedID, true, nil
}
}
resp, err := p.graph.Resources(ctx, armresourcegraph.QueryRequest{
Query: to.Ptr(`resources
| where type =~ 'microsoft.keyvault/vaults' and tostring(properties.vaultUri) =~ '` + url + `'
| project subscriptionId`),
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
}, nil)
if err != nil {
return "", false, cleanError(err)
}
rows, _ := resp.Data.([]any)
for _, r := range rows {
m, _ := r.(map[string]any)
sub, _ := m["subscriptionId"].(string)
if sub != "" && !config.Hidden(p.opts.HideSubscriptions, "", sub) {
return sub, true, nil
}
}
return "", false, nil
}
+419
View File
@@ -0,0 +1,419 @@
package azure
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"net"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
type staticCred struct{ err error }
func (c staticCred) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
if c.err != nil {
return azcore.AccessToken{}, c.err
}
return azcore.AccessToken{Token: "token", ExpiresOn: time.Now().Add(time.Hour)}, nil
}
// fakeAzure serves just enough of ARM and the Key Vault data plane over TLS,
// including Key Vault's 401 auth challenge.
func fakeAzure(t *testing.T, cred azcore.TokenCredential, handler http.HandlerFunc) (*Provider, *httptest.Server) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
w.Header().Set("WWW-Authenticate",
`Bearer authorization="https://login.microsoftonline.com/tenant", resource="https://vault.azure.net"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
handler(w, r)
}))
t.Cleanup(srv.Close)
client := azcore.ClientOptions{
Transport: srv.Client(),
Retry: policy.RetryOptions{MaxRetries: -1},
Cloud: cloud.Configuration{
ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/",
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
cloud.ResourceManager: {Endpoint: srv.URL, Audience: "https://management.azure.com"},
},
},
}
p, err := newWithOptions(cred, options{
arm: &arm.ClientOptions{ClientOptions: client},
client: client,
skipChallengeValidation: true,
})
if err != nil {
t.Fatal(err)
}
return p, srv
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func vault(srv *httptest.Server) provider.Container {
return provider.Container{ID: "/subscriptions/s/vault", Name: "kv", Detail: srv.URL + "/", Types: objectTypes}
}
func TestTokenFailureIsAuthError(t *testing.T) {
login := []string{"az", "login"}
cred := authCredential{cred: staticCred{err: errors.New("AADSTS700082: refresh token expired")}, login: login}
p, srv := fakeAzure(t, cred, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("request sent without a token: %s", r.URL)
})
check := func(name string, err error) {
t.Helper()
var auth *provider.AuthError
if !errors.As(err, &auth) {
t.Fatalf("%s: error %v is not an AuthError", name, err)
}
if !reflect.DeepEqual(auth.Login, login) || !strings.Contains(auth.Error(), "AADSTS700082") {
t.Errorf("%s: unexpected AuthError %+v", name, auth)
}
}
_, err := p.Sources(context.Background())
check("Sources (ARM)", err)
_, err = p.Objects(context.Background(), vault(srv), provider.TypeSecret)
check("Objects (Key Vault)", err)
_, err = p.Containers(context.Background(), provider.Source{ID: "s"})
check("Containers (no ARM fallback on auth errors)", err)
}
func TestAuthErrorNamesAccount(t *testing.T) {
cred := authCredential{cred: staticCred{err: errors.New("AADSTS53003")}, account: "Azure · admin"}
_, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{"x"}})
var auth *provider.AuthError
if !errors.As(err, &auth) || auth.Who() != "Azure · admin" || !strings.HasPrefix(auth.Error(), "Azure · admin:") {
t.Errorf("err = %v", err)
}
}
func TestListSecretsSkipsManagedAndPages(t *testing.T) {
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/secrets" && r.URL.Query().Get("page") == "":
writeJSON(w, 200, map[string]any{
"value": []any{
map[string]any{"id": srvURL + "/secrets/b-secret", "contentType": "text/plain",
"attributes": map[string]any{"enabled": true, "updated": 1767225600}, "tags": map[string]any{"env": "prod"}},
map[string]any{"id": srvURL + "/secrets/tls-cert", "managed": true},
},
"nextLink": srvURL + "/secrets?page=2&api-version=" + r.URL.Query().Get("api-version"),
})
case r.URL.Path == "/secrets":
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": srvURL + "/secrets/a-secret"}}})
default:
http.NotFound(w, r)
}
})
srvURL = srv.URL
got, err := p.Objects(context.Background(), vault(srv), provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
var names []string
for _, o := range got {
names = append(names, o.Name)
}
if !reflect.DeepEqual(names, []string{"a-secret", "b-secret"}) {
t.Fatalf("names = %v, want sorted and without the managed secret", names)
}
b := got[1]
if b.ContentType != "text/plain" || b.Tags["env"] != "prod" || b.Enabled == nil || !*b.Enabled ||
b.Updated.IsZero() || b.Type != provider.TypeSecret {
t.Errorf("metadata not mapped: %+v", b)
}
if want := "@Microsoft.KeyVault(SecretUri=" + srv.URL + "/secrets/b-secret)"; b.Reference != want {
t.Errorf("Reference = %q, want %q", b.Reference, want)
}
}
func TestForbiddenIsShortened(t *testing.T) {
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 403, map[string]any{"error": map[string]any{
"code": "Forbidden", "message": "Caller is not authorized to perform action on resource.",
}})
})
_, err := p.Objects(context.Background(), vault(srv), provider.TypeKey)
if err == nil {
t.Fatal("expected error")
}
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
t.Errorf("error = %q, want %q", got, want)
}
var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 {
t.Error("original ResponseError lost from chain")
}
}
func TestShortenDenied(t *testing.T) {
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
for _, variant := range []string{msg, strings.ReplaceAll(msg, "\r\n", " ")} {
if got := shortenDenied(variant); got != want {
t.Errorf("got %q", got)
}
}
if got := shortenDenied("Public network access is disabled."); got != "Public network access is disabled." {
t.Errorf("unrelated message changed: %q", got)
}
}
func TestSecretValue(t *testing.T) {
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/secrets/db-password/v2" && r.URL.Path != "/secrets/db-password/" {
http.NotFound(w, r)
return
}
writeJSON(w, 200, map[string]any{"id": srvURL + r.URL.Path, "value": "value-of-" + r.URL.Path})
})
srvURL = srv.URL
o := provider.Object{Container: vault(srv), Type: provider.TypeSecret, Name: "db-password"}
for version, want := range map[string]string{"": "value-of-/secrets/db-password/", "v2": "value-of-/secrets/db-password/v2"} {
o.Version = version
v, err := p.Value(context.Background(), o)
if err != nil {
t.Fatal(err)
}
if v.Reveal() != want {
t.Errorf("version %q: got %q", version, v.Reveal())
}
}
}
func TestDescribeCertificate(t *testing.T) {
der, cert := selfSigned(t)
thumb := sha1.Sum(der)
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"id": srvURL + "/certificates/app-tls/abc123",
"cer": base64.StdEncoding.EncodeToString(der),
"x5t": base64.RawURLEncoding.EncodeToString(thumb[:]),
"policy": map[string]any{"secret_props": map[string]any{"contentType": "application/x-pem-file"}},
})
})
srvURL = srv.URL
o, err := p.Describe(context.Background(), provider.Object{Container: vault(srv), Type: provider.TypeCertificate, Name: "app-tls"})
if err != nil {
t.Fatal(err)
}
attrs := map[string]string{}
for _, a := range o.Attrs {
attrs[a.Label] = a.Value
}
if o.Version != "abc123" || o.ContentType != "application/x-pem-file" {
t.Errorf("version/content type = %q/%q", o.Version, o.ContentType)
}
if attrs["subject"] != "CN=app.example.com" || attrs["SANs"] != "app.example.com, 10.0.0.1" ||
!strings.HasPrefix(attrs["thumbprint"], hexColon(thumb[:2])) {
t.Errorf("attrs = %v", attrs)
}
v, err := p.Value(context.Background(), o)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(v.Reveal()))
if block == nil || block.Type != "CERTIFICATE" || !cert.Equal(mustParse(t, block.Bytes)) {
t.Errorf("certificate PEM does not round-trip: %q", v.Reveal())
}
}
func TestPublicKeyPEM(t *testing.T) {
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
ecKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
ecX, ecY := ecPoint(t, ecKey)
for name, tc := range map[string]struct {
jwk azkeys.JSONWebKey
pub any
wantDesc string
}{
"rsa": {
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeRSAHSM), N: rsaKey.N.Bytes(),
E: big.NewInt(int64(rsaKey.E)).Bytes(), KeyOps: []*azkeys.KeyOperation{to.Ptr(azkeys.KeyOperationSign)}},
pub: &rsaKey.PublicKey, wantDesc: "RSA-HSM 2048",
},
"ec": {
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeEC), Crv: to.Ptr(azkeys.CurveNameP384), X: ecX, Y: ecY},
pub: &ecKey.PublicKey, wantDesc: "EC P-384",
},
} {
t.Run(name, func(t *testing.T) {
out, err := publicKeyPEM(&tc.jwk)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(out))
got, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
t.Fatal(err)
}
if !got.(interface{ Equal(crypto.PublicKey) bool }).Equal(tc.pub) {
t.Error("public key does not round-trip")
}
if desc := keyAttrs(&tc.jwk)[0].Value; desc != tc.wantDesc {
t.Errorf("key type = %q, want %q", desc, tc.wantDesc)
}
})
}
if _, err := publicKeyPEM(&azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeOct)}); err == nil {
t.Error("symmetric key should have no public key")
}
}
func TestPortalURLs(t *testing.T) {
id := "/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv"
c := provider.Container{URL: PortalResourceURL("t1", id, "overview")}
if want := "https://portal.azure.com/#@t1/resource" + id + "/overview"; c.URL != want {
t.Errorf("vault URL = %q", c.URL)
}
if got := ObjectListURL(c, provider.TypeCertificate); got != "https://portal.azure.com/#@t1/resource"+id+"/certificates" {
t.Errorf("certificates URL = %q", got)
}
if got := ObjectListURL(provider.Container{}, provider.TypeSecret); got != "" {
t.Errorf("URL without vault page = %q", got)
}
if got := PortalResourceURL("", "", "overview"); got != "" {
t.Errorf("URL without resource = %q", got)
}
if got := portalAssetURL("", "Key", "https://kv.vault.azure.net/keys/k/v1"); got !=
"https://portal.azure.com/#asset/Microsoft_Azure_KeyVault/Key/https://kv.vault.azure.net/keys/k/v1" {
t.Errorf("asset URL = %q", got)
}
}
func TestVersionedReference(t *testing.T) {
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
if got := versionedReference(sec); got != "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s/v1)" {
t.Errorf("secret: %q", got)
}
key := provider.Object{Type: provider.TypeKey, Version: "v2", Reference: "https://kv.vault.azure.net/keys/k"}
if got := versionedReference(key); got != "https://kv.vault.azure.net/keys/k/v2" {
t.Errorf("key: %q", got)
}
}
func TestHiddenSubscriptionsAndPinnedVaults(t *testing.T) {
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"value": []any{
map[string]any{"subscriptionId": "s1", "displayName": "keep-me", "state": "Enabled", "tenantId": "t"},
map[string]any{"subscriptionId": "s2", "displayName": "platform-mgmt", "state": "Enabled", "tenantId": "t"},
map[string]any{"subscriptionId": "s3", "displayName": "by-id", "state": "Enabled", "tenantId": "t"},
}})
})
p.opts.Settings = Settings{
HideSubscriptions: []string{"PLATFORM-MGMT", "s3"},
Vaults: []string{"https://kv-pinned.vault.azure.net"},
}
srcs, err := p.Sources(context.Background())
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range srcs {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"keep-me", "Pinned vaults"}) {
t.Fatalf("sources = %v", names)
}
vaults, err := p.Containers(context.Background(), srcs[1])
if err != nil {
t.Fatal(err)
}
if len(vaults) != 1 || vaults[0].Name != "kv-pinned" || vaults[0].Detail != "https://kv-pinned.vault.azure.net/" ||
len(vaults[0].Types) != 3 {
t.Errorf("pinned vaults = %+v", vaults)
}
}
func TestGraphVault(t *testing.T) {
src := provider.Source{ID: "sub"}
c, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/"})
if !ok || c.Name != "kv" || c.Detail != "https://kv.vault.azure.net/" || len(c.Types) != 3 {
t.Errorf("graphVault = %+v, %v", c, ok)
}
if _, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv"}); ok {
t.Error("row without uri accepted")
}
}
func selfSigned(t *testing.T) ([]byte, *x509.Certificate) {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "app.example.com"},
DNSNames: []string{"app.example.com"},
IPAddresses: []net.IP{net.ParseIP("10.0.0.1")},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return der, mustParse(t, der)
}
func mustParse(t *testing.T, der []byte) *x509.Certificate {
t.Helper()
c, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
return c
}
func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
t.Helper()
b, err := k.PublicKey.Bytes()
if err != nil {
t.Fatal(err)
}
size := (len(b) - 1) / 2
return b[1 : 1+size], b[1+size:]
}
+123
View File
@@ -0,0 +1,123 @@
package azure
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)
// cliCredential gets tokens from the az CLI for one signed-in account.
//
// az can hold several accounts, but `az account get-access-token` can't be
// told which user to use: it picks the account a subscription belongs to.
// So each account is pinned with --subscription. az refuses --subscription
// together with --tenant, which the SDK's AzureCLICredential would pass for
// every Key Vault request (the vault's auth challenge names a tenant), so
// this sends --tenant only for vaults in another tenant.
//
// Tokens are cached in memory until shortly before they expire, so the
// many SDK clients don't each start an az process.
type cliCredential struct {
subscription string // pins the account; "" for az's default account
tenant string // the account's tenant, if known
run func(ctx context.Context, args ...string) ([]byte, error)
mu sync.Mutex
cache map[string]azcore.AccessToken // by resource and tenant argument
}
func newCLICredential(subscription, tenant string) *cliCredential {
return &cliCredential{subscription: subscription, tenant: tenant, run: runAz, cache: map[string]azcore.AccessToken{}}
}
// refreshMargin renews tokens this long before they expire.
const refreshMargin = 5 * time.Minute
func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
if len(opts.Scopes) != 1 {
return azcore.AccessToken{}, errors.New("az: exactly one scope is supported")
}
if opts.Claims != "" {
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
}
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
switch {
case opts.TenantID != "" && opts.TenantID != c.tenant:
// Another tenant: az picks the account itself.
args = append(args, "--tenant", opts.TenantID)
case c.subscription != "":
args = append(args, "--subscription", c.subscription)
}
key := strings.Join(args[5:], " ")
c.mu.Lock()
defer c.mu.Unlock()
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
return tok, nil
}
out, err := c.run(ctx, args...)
if err != nil {
return azcore.AccessToken{}, err
}
tok, err := parseCLIToken(out)
if err != nil {
return azcore.AccessToken{}, err
}
c.cache[key] = tok
return tok, nil
}
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
var t struct {
AccessToken string `json:"accessToken"`
ExpiresOn int64 `json:"expires_on"` // az 2.54+
ExpiresOnS string `json:"expiresOn"` // older az: local time
}
if err := json.Unmarshal(b, &t); err != nil {
return azcore.AccessToken{}, fmt.Errorf("az: unexpected output: %w", err)
}
if t.AccessToken == "" {
return azcore.AccessToken{}, errors.New("az: no access token in output")
}
exp := time.Unix(t.ExpiresOn, 0)
if t.ExpiresOn == 0 {
var err error
exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOnS, time.Local)
if err != nil {
return azcore.AccessToken{}, fmt.Errorf("az: unexpected expiry %q", t.ExpiresOnS)
}
}
return azcore.AccessToken{Token: t.AccessToken, ExpiresOn: exp}, nil
}
// azTimeout bounds one az call; a hung az would otherwise block a pane.
const azTimeout = 30 * time.Second
func runAz(ctx context.Context, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, azTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "az", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if errors.Is(err, exec.ErrNotFound) {
return nil, errors.New("az CLI not found; install it or set AZURE_* for a service principal")
}
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return nil, errors.New(msg)
}
return nil, err
}
return out, nil
}
+320
View File
@@ -0,0 +1,320 @@
package azure
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func deref[T any](p *T) T {
var zero T
if p == nil {
return zero
}
return *p
}
func tags(m map[string]*string) map[string]string {
if len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = deref(v)
}
return out
}
const portal = "https://portal.azure.com/#"
// PortalResourceURL links to a blade of an Azure resource in the portal,
// e.g. "overview" or "secrets". tenant may be empty.
func PortalResourceURL(tenant, resourceID, blade string) string {
if resourceID == "" {
return ""
}
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
}
// portalAssetURL links to a Key Vault object's page; objectID must include
// the version.
func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
}
func tenantPrefix(tenant string) string {
if tenant == "" {
return ""
}
return "@" + tenant + "/"
}
// ObjectListURL links to the vault's list of the given object type,
// derived from the container's overview URL.
func ObjectListURL(c provider.Container, t provider.ObjectType) string {
base, ok := strings.CutSuffix(c.URL, "/overview")
if !ok {
return ""
}
switch t {
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
}
// objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
}
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, "secrets", name)
o := provider.Object{
Container: c,
Type: provider.TypeSecret,
ID: url,
Name: name,
Version: p.ID.Version(),
ContentType: deref(p.ContentType),
Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: ObjectListURL(c, provider.TypeSecret),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
return o
}
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name()
url := objectURL(c, "keys", name)
o := provider.Object{
Container: c,
Type: provider.TypeKey,
ID: url,
Name: name,
Version: p.KID.Version(),
Tags: tags(p.Tags),
Reference: url,
URL: ObjectListURL(c, provider.TypeKey),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
return o
}
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, "certificates", name)
o := provider.Object{
Container: c,
Type: provider.TypeCertificate,
ID: url,
Name: name,
Version: p.ID.Version(),
Tags: tags(p.Tags),
Reference: url,
URL: ObjectListURL(c, provider.TypeCertificate),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
if len(p.X509Thumbprint) > 0 {
o.Attrs = []provider.Attr{{Label: "thumbprint", Value: hexColon(p.X509Thumbprint)}}
}
return o
}
// keyAttrs describes a JSON Web Key, e.g. "RSA-HSM 3072" or "EC P-256".
func keyAttrs(k *azkeys.JSONWebKey) []provider.Attr {
if k == nil {
return nil
}
kty := string(deref(k.Kty))
desc := kty
switch {
case len(k.N) > 0:
desc = fmt.Sprintf("%s %d", kty, new(big.Int).SetBytes(k.N).BitLen())
case k.Crv != nil:
desc = kty + " " + string(*k.Crv)
}
attrs := []provider.Attr{{Label: "key type", Value: desc}}
var ops []string
for _, op := range k.KeyOps {
ops = append(ops, string(deref(op)))
}
if len(ops) > 0 {
attrs = append(attrs, provider.Attr{Label: "ops", Value: strings.Join(ops, ", ")})
}
return attrs
}
// publicKeyPEM encodes the public half of a JSON Web Key as PEM. Key Vault
// never returns private material for keys, and this ignores it regardless.
func publicKeyPEM(k *azkeys.JSONWebKey) (string, error) {
if k == nil {
return "", errors.New("key has no key material")
}
var pub any
switch {
case len(k.N) > 0 && len(k.E) > 0:
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(k.N), E: int(new(big.Int).SetBytes(k.E).Int64())}
case len(k.X) > 0 && len(k.Y) > 0:
var curve elliptic.Curve
switch deref(k.Crv) {
case azkeys.CurveNameP256:
curve = elliptic.P256()
case azkeys.CurveNameP384:
curve = elliptic.P384()
case azkeys.CurveNameP521:
curve = elliptic.P521()
default:
return "", fmt.Errorf("cannot encode curve %s as PEM", deref(k.Crv))
}
size := (curve.Params().BitSize + 7) / 8
point := make([]byte, 1+2*size)
point[0] = 4 // uncompressed
copy(point[1:], leftPad(k.X, size))
copy(point[1+size:], leftPad(k.Y, size))
ec, err := ecdsa.ParseUncompressedPublicKey(curve, point)
if err != nil {
return "", err
}
pub = ec
default:
return "", fmt.Errorf("%s keys have no public key", deref(k.Kty))
}
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", err
}
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))), nil
}
func leftPad(b []byte, n int) []byte {
if len(b) >= n {
return b
}
return append(make([]byte, n-len(b)), b...)
}
// certAttrs describes an X.509 certificate.
func certAttrs(cert *x509.Certificate, thumbprint []byte) []provider.Attr {
attrs := []provider.Attr{
{Label: "subject", Value: cert.Subject.String()},
{Label: "issuer", Value: cert.Issuer.String()},
}
if len(thumbprint) > 0 {
attrs = append(attrs, provider.Attr{Label: "thumbprint", Value: hexColon(thumbprint)})
}
var sans []string
sans = append(sans, cert.DNSNames...)
sans = append(sans, cert.EmailAddresses...)
for _, ip := range cert.IPAddresses {
sans = append(sans, ip.String())
}
for _, u := range cert.URIs {
sans = append(sans, u.String())
}
if len(sans) > 0 {
attrs = append(attrs, provider.Attr{Label: "SANs", Value: strings.Join(sans, ", ")})
}
attrs = append(attrs, provider.Attr{Label: "valid from", Value: cert.NotBefore.Local().Format("2006-01-02 15:04")})
return attrs
}
func certPEM(der []byte) string {
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
}
func hexColon(b []byte) string {
parts := make([]string, len(b))
for i, x := range b {
parts[i] = fmt.Sprintf("%02X", x)
}
return strings.Join(parts, ":")
}
// cleanError shortens the SDK's multi-line HTTP errors to "403 Forbidden:
// <service message>", keeping the original in the chain.
func cleanError(err error) error {
var re *azcore.ResponseError
if !errors.As(err, &re) {
return err
}
msg := re.ErrorCode
if re.RawResponse != nil {
if body, rerr := runtime.Payload(re.RawResponse); rerr == nil {
var payload struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &payload) == nil && payload.Error.Message != "" {
msg = payload.Error.Message
}
}
}
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
}
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
// sentences plus caller and resource IDs, to its first sentence and the
// missing action.
func shortenDenied(msg string) string {
const marker = "Action: '"
i := strings.Index(msg, marker)
if i < 0 {
return msg
}
action, _, ok := strings.Cut(msg[i+len(marker):], "'")
if !ok || action == "" {
return msg
}
first := msg
if k := strings.IndexAny(first, "\r\n"); k >= 0 {
first = first[:k]
}
first, _, _ = strings.Cut(first, ". ")
return strings.TrimSuffix(first, ".") + "; missing " + action
}
type serviceError struct {
status int
msg string
err error
}
func (e *serviceError) Error() string {
s := fmt.Sprintf("%d %s", e.status, http.StatusText(e.status))
if e.msg != "" {
s += ": " + e.msg
}
return s
}
func (e *serviceError) Unwrap() error { return e.err }