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

This commit is contained in:
keb committed 2026-10-12 15:40:46 +02:00
commit edf6e79327
134 files changed
+27239

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
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)
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"
}
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, ", ")
}
+67
View File
@@ -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", "tenant ID": "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) != 4 || 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)
}
}
}
+205
View File
@@ -0,0 +1,205 @@
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
userType string // "user" or "servicePrincipal"
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
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"`
Name string `json:"name"`
State string `json:"state"`
IsDefault bool `json:"isDefault"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantDisplayName"`
Cloud string `json:"environmentName"`
User struct {
Name string `json:"name"`
Type string `json:"type"`
} `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, userType: s.User.Type, cloud: s.Cloud})
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, a.defaultSub = s.ID, enabled, true, s.Name
case a.isDefault:
case a.anchor == "" || enabled && !a.anchorEnabled:
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 {
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("", "", nil), 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.user, a.tenant, func() ([]account, error) { return readAccounts(path) }),
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
}
p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims}
if label != "" {
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
p.label = label
}
out = append(out, p)
}
return out, nil
}
+191
View File
@@ -0,0 +1,191 @@
package azure
import (
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"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("alice@contoso.com", "t1", func() ([]account, error) {
return []account{{user: "alice@contoso.com", tenant: "t1", anchor: "s-alice-2"}}, nil
})
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("", "", nil)
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)
}
}
// jwt is an unsigned token with claims, as az would print it.
func jwt(claims string) string {
enc := base64.RawURLEncoding.EncodeToString
return enc([]byte(`{"alg":"none"}`)) + "." + enc([]byte(claims)) + ".sig"
}
func TestCLICredentialFollowsAzAndRefusesOtherUsers(t *testing.T) {
// az's profile has one entry per subscription: signing in as bob,
// who also sees s-shared, moved it from alice to bob.
profile := []account{
{user: "alice@contoso.com", tenant: "t1", anchor: "s-shared"},
{user: "bob@contoso.com", tenant: "t1", anchor: "s-bob"},
}
var args []string
issuedTo := "alice@contoso.com"
c := newCLICredential("alice@contoso.com", "t1", func() ([]account, error) { return profile, nil })
c.run = func(_ context.Context, a ...string) ([]byte, error) {
args = a
exp := time.Now().Add(time.Hour).Unix()
return []byte(fmt.Sprintf(`{"accessToken": %q, "expires_on": %d}`, jwt(`{"upn":"`+issuedTo+`"}`), exp)), nil
}
arm := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
if _, err := c.GetToken(context.Background(), arm); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(args, " "), "--subscription s-shared") {
t.Errorf("args %q", args)
}
// Now alice has another subscription, and az would hand out bob's
// token for the old one: the new pin is used, and bob's token refused.
profile[0].anchor = "s-alice"
c.cache = map[string]azcore.AccessToken{}
issuedTo = "bob@contoso.com"
_, err := c.GetToken(context.Background(), arm)
if !strings.Contains(strings.Join(args, " "), "--subscription s-alice") {
t.Errorf("args %q", args)
}
if err == nil || !strings.Contains(err.Error(), "token for bob@contoso.com, not alice@contoso.com") {
t.Errorf("err = %v", err)
}
// Alice gone from az entirely: sign in again.
profile = profile[1:]
if _, err := c.GetToken(context.Background(), arm); err == nil || !strings.Contains(err.Error(), "sign in as alice@contoso.com") {
t.Errorf("err = %v", err)
}
}
func TestTokenUser(t *testing.T) {
users := []string{"bob@contoso.com"}
for claims, want := range map[string]bool{
`{"upn":"Bob@Contoso.com"}`: true,
`{"unique_name":"live.com#bob@contoso.com"}`: true,
`{"upn":"alice@contoso.com"}`: false,
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
} {
if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want {
t.Errorf("%s: %v", claims, got)
}
}
if _, got := tokenUser(tokenClaims("not-a-jwt"), users); got {
t.Error("not a JWT")
}
}
+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
}
+521
View File
@@ -0,0 +1,521 @@
// 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
account *accountDetails // nil for a service principal from AZURE_*
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]
}
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, Attrs: graphInfo(m).attrs()}, 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, Attrs: armInfo(v).attrs(),
})
}
}
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
}
+476
View File
@@ -0,0 +1,476 @@
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"
"slices"
"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.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) {
t.Errorf("error = %q, want %q…", got, want)
}
if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
t.Errorf("firewall checks = %q", checks)
}
var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 {
t.Error("original ResponseError lost from chain")
}
}
func TestShortenPolicyDenied(t *testing.T) {
msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287"
if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" {
t.Errorf("shortenDenied = %q", got)
}
}
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:]
}
func TestVaultSettings(t *testing.T) {
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
"private=1 endpoint", "tags=env: prod", "=team: platform"}
check := func(name string, attrs []provider.Attr) {
t.Helper()
var got []string
for _, a := range attrs {
got = append(got, a.Label+"="+a.Value)
}
if !slices.Equal(got, want) {
t.Errorf("%s\n got %q\nwant %q", name, got, want)
}
}
// Resource Graph: the query's columns, as JSON gives them.
c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{
"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast",
"resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"},
"sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true,
"publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0,
})
if !ok {
t.Fatal("row not read")
}
check("resource graph", c.Attrs)
// The management API, when Resource Graph fails.
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast",
"tags": map[string]any{"team": "platform", "env": "prod"},
"properties": map[string]any{
"tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"},
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
}}}})
})
vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"})
if err != nil || len(vaults) != 1 {
t.Fatalf("arm: %v %v", vaults, err)
}
check("management API", vaults[0].Attrs)
}
+212
View File
@@ -0,0 +1,212 @@
package azure
import (
"bytes"
"context"
"encoding/base64"
"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.
//
// Which account a subscription belongs to changes: az's profile has one
// entry per subscription, and signing in to another account that sees it
// moves it there. So the subscription is looked up for every new token,
// and a token az issues for another of the signed-in users is refused.
//
// 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 {
user string // the account's user; "" for az's default account
tenant string // the account's tenant, if known
// accounts reads az's accounts, to pin the user; nil for the default
// account.
accounts func() ([]account, error)
run func(ctx context.Context, args ...string) ([]byte, error)
mu sync.Mutex
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 {
return &cliCredential{user: user, tenant: tenant, accounts: accounts, run: runAz, cache: map[string]azcore.AccessToken{}}
}
// pin is a subscription az maps to the user now, and the other users az
// holds.
func (c *cliCredential) pin() (subscription string, others []string, err error) {
if c.accounts == nil {
return "", nil, nil
}
accts, err := c.accounts()
if err != nil {
return "", nil, err
}
for _, a := range accts {
switch {
case a.user == c.user && a.tenant == c.tenant:
subscription = a.anchor
case !strings.EqualFold(a.user, c.user):
others = append(others, a.user)
}
}
if subscription == "" {
return "", nil, fmt.Errorf("az has no subscriptions for %s any more: sign in as %s", c.user, c.user)
}
return subscription, others, nil
}
// 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")
otherTenant := opts.TenantID != "" && opts.TenantID != c.tenant
key := resource + " " + opts.TenantID
c.mu.Lock()
defer c.mu.Unlock()
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
return tok, nil
}
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
subscription, others, err := c.pin()
switch {
case otherTenant:
// Another tenant: az picks the account itself.
args = append(args, "--tenant", opts.TenantID)
case err != nil:
return azcore.AccessToken{}, err
case subscription != "":
args = append(args, "--subscription", subscription)
}
out, err := c.run(ctx, args...)
if err != nil {
return azcore.AccessToken{}, err
}
tok, err := parseCLIToken(out)
if err != nil {
return azcore.AccessToken{}, err
}
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)
}
if !otherTenant && claims != nil {
c.last = claims
}
c.cache[key] = tok
return tok, nil
}
// tokenClaims reads a JWT's claims, without verifying it: they only
// describe the account and catch az picking the wrong one. nil if it
// isn't a JWT.
func tokenClaims(token string) map[string]any {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil
}
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return nil
}
var claims map[string]any
if json.Unmarshal(b, &claims) != nil {
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"} {
v, _ := claims[name].(string)
if i := strings.LastIndexByte(v, '#'); i >= 0 {
v = v[i+1:] // live.com#user@example.com
}
for _, u := range users {
if v != "" && strings.EqualFold(v, u) {
return u, true
}
}
}
return "", false
}
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
}
+348
View File
@@ -0,0 +1,348 @@
package azure
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net/http"
"regexp"
"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
}
}
}
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
if se.status == http.StatusForbidden {
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
}
return se
}
var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
// 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 {
// Access policies: "The user, group or application 'appid=…;oid=…'
// does not have secrets list permission on key vault 'kv;location=…'".
if m := policyDenied.FindStringSubmatch(msg); m != nil {
return "no " + m[1] + " permission in the vault's access policies"
}
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
}
// deniedChecks are what to check when Key Vault refuses: the network,
// for its firewall's refusals, else access.
func deniedChecks(msg string) []string {
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
strings.Contains(msg, "private link") {
return []string{
"you're on a network the vault lets in (VPN, office)",
"the vault's owner has let your network in",
}
}
return []string{
"your role or group is activated in PIM",
"the vault's owner has given you access: a Key Vault role, or its access policies",
}
}
func (e *serviceError) Unwrap() error { return e.err }
+158
View File
@@ -0,0 +1,158 @@
package azure
import (
"fmt"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// A vault's settings come with the listing, from Resource Graph or the
// Key Vault management API, with no requests of their own: where it is,
// how access to it works, and how it's protected.
type vaultInfo struct {
group, location, sku string
rbac, softDelete, purge *bool
retention *int32
publicAccess, defaultAction string
ipRules, subnets, endpoints int
policies int
tags map[string]string
}
// vaultQuery lists vaults with just the settings shown, not their whole
// properties (access policies can be long).
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri), location, resourceGroup, tags,
sku = tostring(properties.sku.name), rbac = properties.enableRbacAuthorization,
softDelete = properties.enableSoftDelete, retention = properties.softDeleteRetentionInDays,
purge = properties.enablePurgeProtection, publicAccess = tostring(properties.publicNetworkAccess),
defaultAction = tostring(properties.networkAcls.defaultAction),
ipRules = array_length(properties.networkAcls.ipRules),
subnets = array_length(properties.networkAcls.virtualNetworkRules),
endpoints = array_length(properties.privateEndpointConnections),
policies = array_length(properties.accessPolicies)`
// graphInfo reads a vaultQuery row's settings.
func graphInfo(m map[string]any) vaultInfo {
str := func(k string) string { s, _ := m[k].(string); return s }
num := func(k string) int { f, _ := m[k].(float64); return int(f) }
flag := func(k string) *bool {
if b, ok := m[k].(bool); ok {
return &b
}
return nil
}
v := vaultInfo{
group: str("resourceGroup"), location: str("location"), sku: str("sku"),
rbac: flag("rbac"), softDelete: flag("softDelete"), purge: flag("purge"),
publicAccess: str("publicAccess"), defaultAction: str("defaultAction"),
ipRules: num("ipRules"), subnets: num("subnets"), endpoints: num("endpoints"), policies: num("policies"),
}
if f, ok := m["retention"].(float64); ok {
r := int32(f)
v.retention = &r
}
if tags, ok := m["tags"].(map[string]any); ok {
v.tags = map[string]string{}
for k, t := range tags {
v.tags[k] = fmt.Sprint(t)
}
}
return v
}
// armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName
}
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties
if pr == nil {
return info
}
if pr.SKU != nil && pr.SKU.Name != nil {
info.sku = string(*pr.SKU.Name)
}
info.rbac, info.softDelete, info.purge = pr.EnableRbacAuthorization, pr.EnableSoftDelete, pr.EnablePurgeProtection
info.retention, info.publicAccess = pr.SoftDeleteRetentionInDays, deref(pr.PublicNetworkAccess)
if acl := pr.NetworkACLs; acl != nil {
if acl.DefaultAction != nil {
info.defaultAction = string(*acl.DefaultAction)
}
info.ipRules, info.subnets = len(acl.IPRules), len(acl.VirtualNetworkRules)
}
info.endpoints, info.policies = len(pr.PrivateEndpointConnections), len(pr.AccessPolicies)
return info
}
func (v vaultInfo) attrs() []provider.Attr {
var out []provider.Attr
add := func(label, value string) {
if value != "" {
out = append(out, provider.Attr{Label: label, Value: value})
}
}
add("group", v.group)
add("location", v.location)
add("SKU", v.sku)
// Who may read secrets is decided by Azure roles or by the vault's
// own access policies, never both.
if on(v.rbac, false) {
add("access", "Azure roles (RBAC)")
} else {
add("access", fmt.Sprintf("access policies (%d)", v.policies))
}
if on(v.softDelete, true) {
days := int32(90)
if v.retention != nil {
days = *v.retention
}
add("soft delete", fmt.Sprintf("on, %d days", days))
} else {
add("soft delete", "off")
}
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
switch {
case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only")
case strings.EqualFold(v.defaultAction, "deny"):
add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets))
default:
add("network", "all networks")
}
if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
}
keys := make([]string, 0, len(v.tags))
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := ""
if i == 0 {
label = "tags"
}
out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]})
}
return out
}
// on is *b, or def when it isn't set.
func on(b *bool, def bool) bool {
if b == nil {
return def
}
return *b
}