Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d1ae98e696
134 files changed
+27239
No files matched your search
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Package databricks is the Databricks provider. Sources are workspace
|
||||
// profiles from ~/.databrickscfg, containers are secret scopes, and objects
|
||||
// are secrets. Secrets in Azure Key Vault-backed scopes are read through
|
||||
// Databricks, falling back to the vault itself.
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/databricks/databricks-sdk-go/apierr"
|
||||
"github.com/databricks/databricks-sdk-go/client"
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
|
||||
)
|
||||
|
||||
// KeyVault reads Azure Key Vault secrets directly; the Azure provider
|
||||
// implements it. Containers passed to it only have Detail (the vault URL)
|
||||
// set.
|
||||
type KeyVault interface {
|
||||
Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error)
|
||||
Value(ctx context.Context, o provider.Object) (provider.SecretValue, error)
|
||||
}
|
||||
|
||||
type profile struct {
|
||||
name string
|
||||
host string
|
||||
authType string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
profiles []profile
|
||||
keyVault KeyVault // nil disables the fallback
|
||||
newClient func(profile) (*workspace.SecretsAPI, error)
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[string]*workspace.SecretsAPI // by profile name
|
||||
vaults map[string]string // AKV-backed scope ID → vault URL
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
// New reads workspace profiles from the Databricks config file
|
||||
// ($DATABRICKS_CONFIG_FILE or ~/.databrickscfg), leaving out hideProfiles.
|
||||
// A missing file yields a provider with no sources.
|
||||
func New(kv KeyVault, hideProfiles []string) (*Provider, error) {
|
||||
path := os.Getenv("DATABRICKS_CONFIG_FILE")
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = filepath.Join(home, ".databrickscfg")
|
||||
}
|
||||
all, err := readProfiles(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profiles []profile
|
||||
for _, pr := range all {
|
||||
if !config.Hidden(hideProfiles, pr.name, pr.name) {
|
||||
profiles = append(profiles, pr)
|
||||
}
|
||||
}
|
||||
return &Provider{
|
||||
profiles: profiles,
|
||||
keyVault: kv,
|
||||
newClient: profileClient,
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileClient is a client for the secrets API alone. The SDK's
|
||||
// WorkspaceClient would link in every Databricks service, some 10 MB.
|
||||
func profileClient(p profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Profile: p.name})
|
||||
}
|
||||
|
||||
func secretsClient(cfg *dbconfig.Config) (*workspace.SecretsAPI, error) {
|
||||
c, err := client.New(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspace.NewSecrets(c), nil
|
||||
}
|
||||
|
||||
// readProfiles returns the workspace profiles in file order, skipping
|
||||
// account-console profiles, which have no secret scopes.
|
||||
func readProfiles(path string) ([]profile, error) {
|
||||
f, err := ini.Load(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
var out []profile
|
||||
for _, s := range f.Sections() {
|
||||
host := s.Key("host").String()
|
||||
if host == "" || s.HasKey("account_id") || strings.Contains(host, "accounts.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, profile{name: s.Name(), host: host, authType: s.Key("auth_type").String()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loginCommand is what signs the user back in for a profile, or nil if
|
||||
// there is nothing to run (e.g. a personal access token).
|
||||
func loginCommand(p profile) []string {
|
||||
switch p.authType {
|
||||
case "azure-cli":
|
||||
return []string{"az", "login"}
|
||||
case "pat":
|
||||
return nil
|
||||
default:
|
||||
return []string{"databricks", "auth", "login", "--profile", p.name}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return provider.KindDatabricks }
|
||||
func (p *Provider) ID() string { return string(provider.KindDatabricks) }
|
||||
func (p *Provider) Label() string { return string(provider.KindDatabricks) }
|
||||
|
||||
func (p *Provider) Sources(context.Context) ([]provider.Source, error) {
|
||||
out := make([]provider.Source, 0, len(p.profiles))
|
||||
for _, pr := range p.profiles {
|
||||
host := strings.TrimPrefix(pr.host, "https://")
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindDatabricks, ID: pr.name, Name: pr.name,
|
||||
Detail: host, URL: "https://" + host,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) profile(name string) (profile, bool) {
|
||||
for _, pr := range p.profiles {
|
||||
if pr.name == name {
|
||||
return pr, true
|
||||
}
|
||||
}
|
||||
return profile{}, false
|
||||
}
|
||||
|
||||
func (p *Provider) client(name string) (*workspace.SecretsAPI, profile, error) {
|
||||
pr, ok := p.profile(name)
|
||||
if !ok {
|
||||
return nil, pr, fmt.Errorf("unknown profile %q", name)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if c, ok := p.clients[name]; ok {
|
||||
return c, pr, nil
|
||||
}
|
||||
c, err := p.newClient(pr)
|
||||
if err != nil {
|
||||
return nil, pr, classify(pr, err)
|
||||
}
|
||||
p.clients[name] = c
|
||||
return c, pr, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
w, pr, err := p.client(src.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes, err := w.ListScopesAll(ctx)
|
||||
if err != nil {
|
||||
return nil, classify(pr, err)
|
||||
}
|
||||
out := make([]provider.Container, 0, len(scopes))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, s := range scopes {
|
||||
c := provider.Container{Source: src, ID: src.ID + "/" + s.Name, Name: s.Name, Detail: "Databricks-backed"}
|
||||
if s.BackendType == workspace.ScopeBackendTypeAzureKeyvault && s.KeyvaultMetadata != nil {
|
||||
c.Detail = akvDetail + s.KeyvaultMetadata.DnsName
|
||||
c.URL = azure.PortalResourceURL("", s.KeyvaultMetadata.ResourceId, "overview")
|
||||
p.vaults[c.ID] = s.KeyvaultMetadata.DnsName
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Container) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
|
||||
// back to the scope's Detail, so a scope remembered from an earlier session
|
||||
// (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) vaultURL(c provider.Container) string {
|
||||
p.mu.Lock()
|
||||
url := p.vaults[c.ID]
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
url, _ = strings.CutPrefix(c.Detail, akvDetail)
|
||||
if url == c.Detail {
|
||||
url = ""
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const akvDetail = "Azure Key Vault-backed: "
|
||||
|
||||
func secretObject(c provider.Container, key string, updated time.Time) provider.Object {
|
||||
return provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
ID: c.ID + "/" + key,
|
||||
Name: key,
|
||||
Updated: updated,
|
||||
Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
||||
URL: azure.ObjectListURL(c, provider.TypeSecret), // AKV-backed only
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if t != provider.TypeSecret {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
w, pr, err := p.client(c.Source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
secrets, err := w.ListSecretsAll(ctx, workspace.ListSecretsRequest{Scope: c.Name})
|
||||
if err == nil {
|
||||
for _, s := range secrets {
|
||||
var updated time.Time
|
||||
if s.LastUpdatedTimestamp > 0 {
|
||||
updated = time.UnixMilli(s.LastUpdatedTimestamp)
|
||||
}
|
||||
out = append(out, secretObject(c, s.Key, updated))
|
||||
}
|
||||
} else {
|
||||
dbxErr := classify(pr, err)
|
||||
out, err = p.vaultObjects(ctx, c)
|
||||
if err != nil {
|
||||
return nil, combine(dbxErr, err)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
||||
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
||||
url := p.vaultURL(c)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return nil, errNoFallback
|
||||
}
|
||||
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]provider.Object, 0, len(secrets))
|
||||
for _, s := range secrets {
|
||||
out = append(out, secretObject(c, s.Name, s.Updated))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, ctx.Err()
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
w, pr, err := p.client(o.Container.Source.ID)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
resp, err := w.GetSecret(ctx, workspace.GetSecretRequest{Scope: o.Container.Name, Key: o.Name})
|
||||
if err == nil {
|
||||
b, derr := base64.StdEncoding.DecodeString(resp.Value)
|
||||
if derr != nil {
|
||||
return provider.SecretValue{}, fmt.Errorf("decoding secret: %w", derr)
|
||||
}
|
||||
return provider.NewSecretValue(b), nil
|
||||
}
|
||||
dbxErr := classify(pr, err)
|
||||
|
||||
url := p.vaultURL(o.Container)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return provider.SecretValue{}, dbxErr
|
||||
}
|
||||
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
||||
Container: provider.Container{Detail: url},
|
||||
Type: provider.TypeSecret,
|
||||
Name: o.Name,
|
||||
})
|
||||
if kvErr != nil {
|
||||
return provider.SecretValue{}, combine(dbxErr, kvErr)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Databricks refuses: the network,
|
||||
// when the workspace's IP access list refused, else access, and for a
|
||||
// profile signed in through az, which account az is on.
|
||||
func deniedChecks(pr profile, msg string) []string {
|
||||
if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") {
|
||||
return []string{
|
||||
"you're on a network the workspace lets in (VPN, office)",
|
||||
"the workspace's admin has let your network in",
|
||||
}
|
||||
}
|
||||
var checks []string
|
||||
if pr.authType == "azure-cli" {
|
||||
checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)")
|
||||
}
|
||||
return append(checks,
|
||||
"you have access to the workspace",
|
||||
"the scope's manager has given you READ on it, or a group you're in",
|
||||
)
|
||||
}
|
||||
|
||||
var errNoFallback = errors.New("no Key Vault fallback")
|
||||
|
||||
// combine reports both the Databricks and the Key Vault failure, with
|
||||
// both their checks after. An auth error from either stays reachable
|
||||
// with errors.As.
|
||||
func combine(dbxErr, kvErr error) error {
|
||||
if errors.Is(kvErr, errNoFallback) {
|
||||
return dbxErr
|
||||
}
|
||||
var checks []string
|
||||
strip := func(err error) error {
|
||||
if d, ok := err.(*provider.Denied); ok {
|
||||
for _, c := range d.Checks {
|
||||
if !slices.Contains(checks, c) {
|
||||
checks = append(checks, c)
|
||||
}
|
||||
}
|
||||
return d.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr))
|
||||
if len(checks) > 0 {
|
||||
return &provider.Denied{Err: err, Checks: checks}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// classify turns authentication failures into *provider.AuthError: a 401
|
||||
// from the API, or failing to get a token before the request was sent.
|
||||
func classify(pr profile, err error) error {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
var api *apierr.APIError
|
||||
if errors.As(err, &api) {
|
||||
if api.StatusCode == 403 {
|
||||
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
|
||||
}
|
||||
if api.StatusCode != 401 {
|
||||
return err
|
||||
}
|
||||
} else if !strings.Contains(err.Error(), "cannot get access token") &&
|
||||
!strings.Contains(err.Error(), " auth: ") {
|
||||
return err
|
||||
}
|
||||
return &provider.AuthError{Kind: provider.KindDatabricks, Login: loginCommand(pr), Err: err}
|
||||
}
|
||||
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
|
||||
// Locate finds the profile for a workspace host. It doesn't contact the
|
||||
// workspace.
|
||||
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
|
||||
want := normalizeHost(host)
|
||||
for _, pr := range p.profiles {
|
||||
if normalizeHost(pr.host) == want {
|
||||
return pr.name, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func normalizeHost(h string) string {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
h = strings.TrimPrefix(h, "https://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestReadProfiles(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "databrickscfg")
|
||||
cfg := `[DEFAULT]
|
||||
host = https://adb-1.1.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[INFRA-DEV]
|
||||
host = adb-2.2.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[ACCOUNT]
|
||||
host = https://accounts.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[acct-by-id]
|
||||
host = https://example.cloud.databricks.com
|
||||
account_id = 123
|
||||
|
||||
[oauth]
|
||||
host = https://adb-3.3.azuredatabricks.net
|
||||
auth_type = databricks-cli
|
||||
|
||||
[no-host]
|
||||
token = x
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readProfiles(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []profile{
|
||||
{name: "DEFAULT", host: "https://adb-1.1.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "INFRA-DEV", host: "adb-2.2.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "oauth", host: "https://adb-3.3.azuredatabricks.net", authType: "databricks-cli"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("profiles = %+v\nwant %+v", got, want)
|
||||
}
|
||||
|
||||
if got, err := readProfiles(filepath.Join(t.TempDir(), "missing")); err != nil || got != nil {
|
||||
t.Errorf("missing file: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCommand(t *testing.T) {
|
||||
for authType, want := range map[string][]string{
|
||||
"azure-cli": {"az", "login"},
|
||||
"databricks-cli": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"pat": nil,
|
||||
} {
|
||||
if got := loginCommand(profile{name: "p", authType: authType}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%q: %v, want %v", authType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKeyVault stands in for the Azure provider.
|
||||
type fakeKeyVault struct {
|
||||
secrets map[string]string // name → value, for vault URL kvURL
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
const kvURL = "https://kv-app-prod.vault.azure.net/"
|
||||
|
||||
func (k *fakeKeyVault) Objects(_ context.Context, c provider.Container, _ provider.ObjectType) ([]provider.Object, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return nil, k.err
|
||||
}
|
||||
if c.Detail != kvURL {
|
||||
return nil, errors.New("wrong vault " + c.Detail)
|
||||
}
|
||||
var out []provider.Object
|
||||
for name := range k.secrets {
|
||||
out = append(out, provider.Object{Name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *fakeKeyVault) Value(_ context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return provider.SecretValue{}, k.err
|
||||
}
|
||||
if o.Container.Detail != kvURL {
|
||||
return provider.SecretValue{}, errors.New("wrong vault " + o.Container.Detail)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(k.secrets[o.Name])), nil
|
||||
}
|
||||
|
||||
// workspaceAPI serves the secrets API. The "akv" scope behaves like an Azure
|
||||
// Key Vault-backed scope whose secrets Databricks refuses to read.
|
||||
func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
write := func(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status != 0 {
|
||||
write(w, status, map[string]any{"error_code": "UNAUTHENTICATED", "message": "Token is expired"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
switch {
|
||||
case r.URL.Path == "/api/2.0/secrets/scopes/list":
|
||||
write(w, 200, map[string]any{"scopes": []any{
|
||||
map[string]any{"name": "app", "backend_type": "DATABRICKS"},
|
||||
map[string]any{"name": "akv", "backend_type": "AZURE_KEYVAULT",
|
||||
"keyvault_metadata": map[string]any{"dns_name": kvURL, "resource_id": "/x/kv-app-prod"}},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"secrets": []any{
|
||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||
map[string]any{"key": "client-secret"},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
|
||||
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
|
||||
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
||||
case strings.HasPrefix(r.URL.Path, "/api/2.0/secrets/") && q.Get("scope") == "akv":
|
||||
write(w, 400, map[string]any{"error_code": "BAD_REQUEST",
|
||||
"message": "Reading secrets from Azure KeyVault-backed scopes is not supported"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, srv *httptest.Server, kv KeyVault) *Provider {
|
||||
t.Helper()
|
||||
return &Provider{
|
||||
profiles: []profile{{name: "prod", host: srv.URL, authType: "azure-cli"}},
|
||||
keyVault: kv,
|
||||
newClient: func(profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Host: srv.URL, Token: "token"})
|
||||
},
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func scopes(t *testing.T, p *Provider) map[string]provider.Container {
|
||||
t.Helper()
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
cs, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Container{}
|
||||
for _, c := range cs {
|
||||
out[c.Name] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestScopesSecretsAndValues(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
sc := scopes(t, p)
|
||||
if sc["app"].Detail != "Databricks-backed" || sc["akv"].Detail != "Azure Key Vault-backed: "+kvURL {
|
||||
t.Errorf("scope details = %q / %q", sc["app"].Detail, sc["akv"].Detail)
|
||||
}
|
||||
|
||||
objs, err := p.Objects(context.Background(), sc["app"], provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 2 || objs[0].Name != "client-secret" || objs[1].Name != "storage-key" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
if objs[1].Updated.IsZero() || objs[1].Reference != "{{secrets/app/storage-key}}" {
|
||||
t.Errorf("metadata = %+v", objs[1])
|
||||
}
|
||||
|
||||
v, err := p.Value(context.Background(), objs[1])
|
||||
if err != nil || v.Reveal() != "value-of-storage-key" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVScopeFallsBackToKeyVault(t *testing.T) {
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
objs, err := p.Objects(context.Background(), akv, provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 1 || objs[0].Name != "db-password" || objs[0].Reference != "{{secrets/akv/db-password}}" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
v, err := p.Value(context.Background(), objs[0])
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackForRememberedScope(t *testing.T) {
|
||||
// A favourite from an earlier session: scopes not listed yet.
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := provider.Container{
|
||||
Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod"},
|
||||
ID: "prod/akv", Name: "akv", Detail: "Azure Key Vault-backed: " + kvURL,
|
||||
}
|
||||
v, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackFailureReportsBoth(t *testing.T) {
|
||||
kvAuth := &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("expired")}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), &fakeKeyVault{err: kvAuth})
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
_, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "Key Vault fallback") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Error("Key Vault auth error not reachable for the sign-in prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFallbackForDatabricksBackedScopes(t *testing.T) {
|
||||
kv := &fakeKeyVault{}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
app := scopes(t, p)["app"]
|
||||
if _, err := p.Value(context.Background(), provider.Object{Container: app, Name: "missing"}); err != nil {
|
||||
// app/get returns a value for any key; just make sure the vault wasn't asked
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv.calls != 0 {
|
||||
t.Errorf("Key Vault consulted for a Databricks-backed scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedIsAuthError(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 401), nil)
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
_, err := p.Containers(context.Background(), srcs[0])
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Fatalf("err = %v, want AuthError", err)
|
||||
}
|
||||
if auth.Kind != provider.KindDatabricks || !reflect.DeepEqual(auth.Login, []string{"az", "login"}) {
|
||||
t.Errorf("auth = %+v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
pr := profile{name: "p", authType: "databricks-cli"}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(classify(pr, errors.New("default auth: azure-cli: cannot get access token: run az login")), &auth) {
|
||||
t.Error("token failure not classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, errors.New("dial tcp: no such host")), &auth) {
|
||||
t.Error("network error classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, context.Canceled), &auth) {
|
||||
t.Error("cancellation classified as auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedSaysWhatToCheck(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
|
||||
var d *provider.Denied
|
||||
// The test profile signs in through az: that's the first check.
|
||||
if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") ||
|
||||
!strings.Contains(err.Error(), "\n• you have access to the workspace") {
|
||||
t.Errorf("err %q", err)
|
||||
}
|
||||
if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") {
|
||||
t.Errorf("a token profile's checks %q", got)
|
||||
}
|
||||
if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") {
|
||||
t.Errorf("IP ACL checks %q", got)
|
||||
}
|
||||
|
||||
// Both refused: one message, then both lists of checks.
|
||||
err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}},
|
||||
&provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}})
|
||||
if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" {
|
||||
t.Errorf("combined %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// Package fake is an in-memory provider used by tests and --demo mode.
|
||||
package fake
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Provider serves a fixed tree of sources, containers and objects,
|
||||
// optionally sleeping before every call to simulate network latency.
|
||||
type Provider struct {
|
||||
kind provider.Kind
|
||||
id, label string
|
||||
delay time.Duration
|
||||
sources []provider.Source
|
||||
containers map[string][]provider.Container // by source ID
|
||||
objects map[listKey][]provider.Object
|
||||
errs map[listKey]error
|
||||
values map[string]string // by object ID
|
||||
versions map[string][]provider.Object // by object ID
|
||||
details map[string][]provider.Attr // by container ID
|
||||
}
|
||||
|
||||
type listKey struct {
|
||||
container string
|
||||
typ provider.ObjectType
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
func newProvider(kind provider.Kind, delay time.Duration) *Provider {
|
||||
return &Provider{
|
||||
kind: kind,
|
||||
delay: delay,
|
||||
containers: map[string][]provider.Container{},
|
||||
objects: map[listKey][]provider.Object{},
|
||||
errs: map[listKey]error{},
|
||||
values: map[string]string{},
|
||||
versions: map[string][]provider.Object{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return p.kind }
|
||||
|
||||
func (p *Provider) ID() string {
|
||||
if p.id != "" {
|
||||
return p.id
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
func (p *Provider) Label() string {
|
||||
if p.label != "" {
|
||||
return p.label
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
// As renames the provider, to simulate several accounts of one kind.
|
||||
func (p *Provider) As(id, label string) *Provider {
|
||||
p.id, p.label = id, label
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.sources, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.containers[src.ID], nil
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := listKey{p.containerID(c), t}
|
||||
if err := p.errs[k]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.objects[k], nil
|
||||
}
|
||||
|
||||
// Describe returns o unchanged; the fixtures already carry their details.
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, ctx.Err()
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.kind != provider.KindAzure { // only Key Vault keeps versions
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
vs := p.versions[o.ID]
|
||||
if len(vs) == 0 { // every object has at least its current version
|
||||
return []provider.Object{o}, nil
|
||||
}
|
||||
out := make([]provider.Object, len(vs))
|
||||
for i, v := range vs {
|
||||
v.Container, v.Type, v.ID, v.Name, v.Reference, v.URL = o.Container, o.Type, o.ID, o.Name, o.Reference, o.URL
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
if o.ID == "" { // built from a link, like the real providers read it
|
||||
o.ID = p.containerID(o.Container) + "/" + strings.ToLower(string(cmp.Or(o.Type, provider.TypeSecret))) + "/" + o.Name
|
||||
}
|
||||
v, ok := p.values[o.ID+"@"+o.Version]
|
||||
if !ok {
|
||||
v, ok = p.values[o.ID]
|
||||
}
|
||||
if !ok {
|
||||
return provider.SecretValue{}, fmt.Errorf("%s %q not found", strings.ToLower(string(o.Type)), o.Name)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(v)), nil
|
||||
}
|
||||
|
||||
// Locate finds the source holding the vault (by host) or the workspace.
|
||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, src := range p.sources {
|
||||
if p.kind == provider.KindDatabricks && src.Detail == host {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
for _, c := range p.containers[src.ID] {
|
||||
if c.Detail == "https://"+host {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (p *Provider) wait(ctx context.Context) error {
|
||||
if p.delay <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
t := time.NewTimer(p.delay)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
const portal = "https://portal.azure.com/#resource"
|
||||
|
||||
// containerID finds a container by ID, or by vault URL like the real Azure
|
||||
// provider, which only needs the URL (vaults opened from links).
|
||||
func (p *Provider) containerID(c provider.Container) string {
|
||||
for _, cs := range p.containers {
|
||||
for _, x := range cs {
|
||||
if x.ID == c.ID {
|
||||
return x.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not one listed here (built from a link): by vault URL, or a scope
|
||||
// by name in its workspace.
|
||||
for _, cs := range p.containers {
|
||||
for _, x := range cs {
|
||||
if c.Detail != "" && strings.TrimSuffix(x.Detail, "/") == strings.TrimSuffix(c.Detail, "/") {
|
||||
return x.ID
|
||||
}
|
||||
if c.Detail == "" && c.Name == x.Name && c.Source.ID != "" && c.Source.ID == x.Source.ID {
|
||||
return x.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (p *Provider) addSource(id, name, detail string) provider.Source {
|
||||
s := provider.Source{Kind: p.kind, ID: id, Name: name, Detail: detail}
|
||||
if p.kind == provider.KindAzure {
|
||||
s.URL = portal + "/subscriptions/" + id + "/overview"
|
||||
} else {
|
||||
s.URL = "https://" + detail
|
||||
}
|
||||
p.sources = append(p.sources, s)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Provider) addContainer(src provider.Source, name, detail string, types ...provider.ObjectType) provider.Container {
|
||||
c := provider.Container{Source: src, ID: src.ID + "/" + name, Name: name, Detail: detail, Types: types}
|
||||
vault := name
|
||||
if p.kind == provider.KindDatabricks {
|
||||
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
||||
}
|
||||
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
|
||||
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
||||
}
|
||||
p.containers[src.ID] = append(p.containers[src.ID], c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
||||
o.Container = c
|
||||
o.Type = t
|
||||
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
||||
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
||||
o.URL = base + map[provider.ObjectType]string{
|
||||
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
|
||||
}[t]
|
||||
}
|
||||
k := listKey{c.ID, t}
|
||||
p.objects[k] = append(p.objects[k], o)
|
||||
p.values[o.ID] = value
|
||||
return o
|
||||
}
|
||||
|
||||
func (p *Provider) fail(c provider.Container, err error, types ...provider.ObjectType) {
|
||||
for _, t := range types {
|
||||
p.errs[listKey{c.ID, t}] = err
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
||||
yes = true
|
||||
no = false
|
||||
)
|
||||
|
||||
// ErrForbidden mimics a data-plane permission error from a backend.
|
||||
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
||||
|
||||
// ErrForbiddenKeys mimics a vault where the caller can read secrets but not keys.
|
||||
var ErrForbiddenKeys = errors.New("403 Forbidden: caller lacks permission to list keys")
|
||||
|
||||
const (
|
||||
demoPublicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAdemo\n-----END PUBLIC KEY-----"
|
||||
demoCert = "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUDEMO\nDEMODEMODEMO\n-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||
|
||||
// Azure returns a fake Azure provider with two subscriptions.
|
||||
func Azure(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
objectURL := func(vault, kind, name string) string {
|
||||
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
||||
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
|
||||
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
||||
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
||||
}, "correct-horse-battery-staple")
|
||||
p.versions[dbPassword.ID] = []provider.Object{
|
||||
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
||||
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
||||
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
|
||||
}
|
||||
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
||||
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Reference: secretRef("kv-app-prod", "api-key"),
|
||||
}, "sk_live_51HxDEMO0000000000000000")
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
||||
Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||
}, "deprecated")
|
||||
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "RSA 2048"},
|
||||
{Label: "ops", Value: "sign, verify"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
||||
}, demoPublicKey)
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "EC P-256"},
|
||||
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "keys", "data-encryption"),
|
||||
}, demoPublicKey)
|
||||
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
|
||||
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=app.example.com"},
|
||||
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
||||
{Label: "thumbprint", Value: "3B:1F:9A:0C:44:E2:7D:15"},
|
||||
{Label: "SANs", Value: "app.example.com, www.app.example.com"},
|
||||
{Label: "valid from", Value: "2026-08-01 09:30"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
||||
}, demoCert)
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=old.example.com"},
|
||||
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
||||
{Label: "thumbprint", Value: "A0:77:12:DE:9B:03:C6:58"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "certificates", "old-tls"),
|
||||
}, demoCert)
|
||||
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
||||
|
||||
locked := p.addContainer(prod, "kv-restricted", "https://kv-restricted.vault.azure.net", allTypes...)
|
||||
p.fail(locked, ErrForbidden, allTypes...)
|
||||
|
||||
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
||||
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||
}, "dev-password")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// AzureOps returns a second fake Azure account. It shares platform-prod
|
||||
// with Azure but sees a different vault there, and has a subscription of
|
||||
// its own, like an admin account next to a personal one.
|
||||
func AzureOps(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||
}, "break-glass-demo-password")
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
// Unlike the first account, ops may read keys here.
|
||||
p.add(shared, provider.TypeKey, provider.Object{
|
||||
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
||||
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
||||
}, demoPublicKey)
|
||||
|
||||
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||
}, "pd-demo-token")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Databricks returns a fake Databricks provider with two workspace profiles,
|
||||
// including an Azure Key Vault-backed scope. Scopes hold secrets only.
|
||||
func Databricks(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindDatabricks, delay)
|
||||
secret := func(c provider.Container, key, value string) {
|
||||
p.add(c, provider.TypeSecret, provider.Object{
|
||||
Name: key, Updated: base, Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
||||
}, value)
|
||||
}
|
||||
|
||||
prod := p.addSource("dbx-prod", "prod", "adb-1111111111111111.11.azuredatabricks.net")
|
||||
app := p.addContainer(prod, "app", "Databricks-backed")
|
||||
secret(app, "storage-key", "storage-demo-key")
|
||||
secret(app, "sp-client-secret", "sp-demo-secret")
|
||||
akv := p.addContainer(prod, "kv-app-prod", "Azure Key Vault-backed: kv-app-prod")
|
||||
secret(akv, "db-password", "correct-horse-battery-staple")
|
||||
|
||||
dev := p.addSource("dbx-dev", "dev", "adb-2222222222222222.2.azuredatabricks.net")
|
||||
devApp := p.addContainer(dev, "app", "Databricks-backed")
|
||||
secret(devApp, "storage-key", "dev-storage-key")
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
var _ provider.ContainerDetails = (*Provider)(nil)
|
||||
|
||||
// Details returns a file's details, like the repo provider's.
|
||||
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||
attrs, ok := p.details[c.ID]
|
||||
if !ok {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
return attrs, ctx.Err()
|
||||
}
|
||||
|
||||
// Repos returns a fake repo provider: three git repos with SOPS and plain
|
||||
// .env files, the first one the repo lazysecrets was started in.
|
||||
func Repos() *Provider {
|
||||
p := newProvider(provider.KindRepo, 0)
|
||||
p.id, p.label = "Repos", "Repos"
|
||||
p.details = map[string][]provider.Attr{}
|
||||
|
||||
repo := func(name string, here bool) provider.Source {
|
||||
s := p.addSource("/home/dev/git/"+name, name, "github.com/acme/"+name)
|
||||
s.Here = here
|
||||
p.sources[len(p.sources)-1] = s
|
||||
return s
|
||||
}
|
||||
// file adds a file and its keys, alternating name and value. git and
|
||||
// commit are what git says of it: "tracked", "ignored" or "untracked".
|
||||
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
|
||||
sops := method != ""
|
||||
detail := "env · plain text"
|
||||
if sops {
|
||||
detail = "sops · " + method
|
||||
}
|
||||
c := p.addContainer(src, path, detail, provider.TypeSecret)
|
||||
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
|
||||
if sops {
|
||||
attrs = []provider.Attr{{Label: "format", Value: "sops " + format(path)}, {Label: "encryption", Value: method}}
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
o := provider.Object{Name: kv[i], Attrs: attrs}
|
||||
if sops {
|
||||
o.Updated = base
|
||||
}
|
||||
p.add(c, provider.TypeSecret, o, kv[i+1])
|
||||
}
|
||||
|
||||
d := []provider.Attr{{Label: "encryption", Value: "none: plain text"}}
|
||||
if sops {
|
||||
d = []provider.Attr{{Label: "encryption", Value: detail}}
|
||||
keys := map[string]string{
|
||||
"age": "age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p",
|
||||
"azure_kv": "azure_kv https://kv-sops-prod.vault.azure.net/keys/sops/1f2e3d4c",
|
||||
}
|
||||
for i, m := range strings.Split(method, ", ") {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "keys" // on the first only, like the repo provider
|
||||
}
|
||||
d = append(d, provider.Attr{Label: label, Value: keys[m]})
|
||||
}
|
||||
d = append(d, provider.Attr{Label: "encrypted", Value: base.Local().Format("2006-01-02 15:04")})
|
||||
}
|
||||
switch {
|
||||
case git == "tracked" && !sops:
|
||||
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
case git == "untracked":
|
||||
d = append(d, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !sops})
|
||||
default:
|
||||
d = append(d, provider.Attr{Label: "git", Value: git})
|
||||
}
|
||||
if commit != "" {
|
||||
d = append(d, provider.Attr{Label: "commit", Value: commit})
|
||||
}
|
||||
p.details[c.ID] = d
|
||||
}
|
||||
|
||||
api := repo("lazysecrets-demo", true)
|
||||
file(api, "deploy/prod/secrets.yaml", "age, azure_kv", "tracked", "4f9c2ab · 3 days ago",
|
||||
"database.password", "pr0d-Pg-7f3k9QwZ",
|
||||
"database.url", "postgres://app:pr0d-Pg-7f3k9QwZ@pg-demo-prod:5432/app",
|
||||
"stripe.secret_key", "sk_live_demo_51NxDemoKey0000",
|
||||
"jwt.signing_key", "c2lnbmluZy1rZXktZm9yLXRoZS1kZW1v",
|
||||
"gcp.service_account", `{"type":"service_account","project_id":"lazysecrets-demo","client_email":"app@lazysecrets-demo.iam.gserviceaccount.com","private_key_id":"demo1234"}`)
|
||||
file(api, "deploy/staging/secrets.yaml", "age", "tracked", "a71e05d · 2 weeks ago",
|
||||
"database.password", "stg-Pg-2b8d1L",
|
||||
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
|
||||
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
|
||||
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
|
||||
"github.client_secret", "ghs_demo0000000000000000000000",
|
||||
"google.client_secret", "GOCSPX-demo-000000000000")
|
||||
file(api, ".env", "", "ignored", "",
|
||||
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
|
||||
"STRIPE_SECRET_KEY", "sk_test_demo_local",
|
||||
"LOG_LEVEL", "debug")
|
||||
|
||||
infra := repo("platform-infra", false)
|
||||
file(infra, "terraform/prod/secrets.enc.yaml", "azure_kv", "tracked", "9d40c3e · yesterday",
|
||||
"postgres.admin_password", "infra-Pg-admin-0Xq",
|
||||
"grafana.admin_password", "grafana-demo-admin",
|
||||
"acr.pull_token", "acr-demo-pull-token")
|
||||
file(infra, "k8s/monitoring/alertmanager.enc.yaml", "age", "tracked", "1c58e7a · 5 days ago",
|
||||
"slack.webhook_url", "https://hooks.slack.com/services/T000/B000/demo",
|
||||
"pagerduty.routing_key", "pd-demo-routing-key")
|
||||
file(infra, "scripts/.env", "", "tracked", "77b2d90 · 8 months ago",
|
||||
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
|
||||
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
web := repo("web-frontend", false)
|
||||
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
|
||||
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
|
||||
"NEXT_PUBLIC_API_URL", "https://api.acme.example")
|
||||
file(web, ".env.local", "", "untracked", "",
|
||||
"NEXT_PUBLIC_API_URL", "http://localhost:8080",
|
||||
"ANALYTICS_KEY", "demo-analytics-key")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// format is a SOPS file's format, by its name.
|
||||
func format(path string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".json"):
|
||||
return "json"
|
||||
case strings.HasSuffix(path, ".yaml"):
|
||||
return "yaml"
|
||||
}
|
||||
return "dotenv"
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Package provider defines the read-only interface every secret backend
|
||||
// implements, and the types the UI works with.
|
||||
//
|
||||
// All backends map onto the same hierarchy:
|
||||
//
|
||||
// Source Azure subscription | Databricks workspace profile
|
||||
// Container Key Vault | secret scope
|
||||
// Object secret, key or certificate | secret
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind identifies a backend.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindAzure Kind = "Azure"
|
||||
KindDatabricks Kind = "Databricks"
|
||||
// KindRepo is a git repository's encrypted secret files (SOPS).
|
||||
KindRepo Kind = "Repo"
|
||||
)
|
||||
|
||||
// AuthError reports that the user must (re)authenticate with a backend, for
|
||||
// example because their az CLI login has expired.
|
||||
type AuthError struct {
|
||||
Kind Kind
|
||||
// Account names the identity when a provider has several, e.g.
|
||||
// "Azure · admin.alice"; empty otherwise.
|
||||
Account string
|
||||
// Login is the command that signs the user in, e.g. ["az", "login"].
|
||||
Login []string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
return fmt.Sprintf("%s: not signed in or session expired: %v", e.Who(), e.Err)
|
||||
}
|
||||
|
||||
// Who is the account, or the kind if there is just one.
|
||||
func (e *AuthError) Who() string {
|
||||
if e.Account != "" {
|
||||
return e.Account
|
||||
}
|
||||
return string(e.Kind)
|
||||
}
|
||||
|
||||
func (e *AuthError) Unwrap() error { return e.Err }
|
||||
|
||||
// ErrUnsupported is returned by optional operations a backend cannot perform,
|
||||
// such as listing versions of a Databricks secret.
|
||||
var ErrUnsupported = errors.New("not supported by this provider")
|
||||
|
||||
// Source is a top-level account: an Azure subscription or a Databricks
|
||||
// workspace profile.
|
||||
type Source struct {
|
||||
Kind Kind
|
||||
// Provider is the ID of the provider instance the source came from.
|
||||
// The app sets it; it routes requests when several providers share a
|
||||
// Kind, e.g. one per Azure account.
|
||||
Provider string
|
||||
// ProviderLabel is that provider's Label, also set by the app.
|
||||
ProviderLabel string
|
||||
ID string
|
||||
Name string
|
||||
Detail string // e.g. tenant or workspace host
|
||||
URL string // web page for the source, if any
|
||||
// Here marks the source lazysecrets was started in (the current git
|
||||
// repo): listed first, starred or not, and labelled.
|
||||
Here bool
|
||||
}
|
||||
|
||||
// ObjectType is the kind of object stored in a container.
|
||||
type ObjectType string
|
||||
|
||||
const (
|
||||
TypeSecret ObjectType = "Secret"
|
||||
TypeKey ObjectType = "Key"
|
||||
TypeCertificate ObjectType = "Certificate"
|
||||
)
|
||||
|
||||
// Container holds objects: a Key Vault or a Databricks secret scope.
|
||||
type Container struct {
|
||||
Source Source
|
||||
ID string
|
||||
Name string
|
||||
Detail string // e.g. vault URL, or "Azure Key Vault-backed: kv-name"
|
||||
// Types lists the object types the container holds; empty means
|
||||
// secrets only.
|
||||
Types []ObjectType
|
||||
URL string // web page for the container, if any
|
||||
// Attrs describe it further, from the listing (a vault's settings).
|
||||
Attrs []Attr `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||
func (c Container) ObjectTypes() []ObjectType {
|
||||
if len(c.Types) == 0 {
|
||||
return []ObjectType{TypeSecret}
|
||||
}
|
||||
return c.Types
|
||||
}
|
||||
|
||||
// Attr is a type-specific metadata field, shown in provider order.
|
||||
type Attr struct {
|
||||
Label string
|
||||
Value string
|
||||
Warn bool // worth a look (a plain .env tracked by git): shown in red
|
||||
}
|
||||
|
||||
// Object describes a secret, key or certificate without its value.
|
||||
type Object struct {
|
||||
Container Container
|
||||
Type ObjectType
|
||||
ID string
|
||||
Name string
|
||||
Version string
|
||||
Enabled *bool
|
||||
Updated time.Time
|
||||
Expires time.Time
|
||||
ContentType string
|
||||
Tags map[string]string
|
||||
// Attrs holds type-specific metadata, e.g. key type and size, or a
|
||||
// certificate's subject, issuer and thumbprint.
|
||||
Attrs []Attr
|
||||
// Reference is how the object is referenced from config, e.g.
|
||||
// "@Microsoft.KeyVault(SecretUri=...)" or "{{secrets/scope/key}}".
|
||||
Reference string
|
||||
URL string // web page for the object (or its list), if any
|
||||
}
|
||||
|
||||
// Provider is a read-only secret backend. Every method may block on the
|
||||
// network and must honour ctx cancellation.
|
||||
type Provider interface {
|
||||
Kind() Kind
|
||||
// ID identifies the instance, stable across runs, e.g. "Azure" or
|
||||
// "Azure:user@example.com/tenant".
|
||||
ID() string
|
||||
// Label names the instance in the UI, e.g. "Azure" or "Azure · user".
|
||||
Label() string
|
||||
Sources(ctx context.Context) ([]Source, error)
|
||||
Containers(ctx context.Context, src Source) ([]Container, error)
|
||||
// Objects lists objects of type t, one of c.ObjectTypes(). It returns
|
||||
// metadata only; values are fetched with Value.
|
||||
Objects(ctx context.Context, c Container, t ObjectType) ([]Object, error)
|
||||
// Describe returns o with details that listing doesn't include, such as
|
||||
// a key's type or a certificate's subject. It never fetches secret
|
||||
// values; it may return o unchanged.
|
||||
Describe(ctx context.Context, o Object) (Object, error)
|
||||
// Versions may return ErrUnsupported.
|
||||
Versions(ctx context.Context, o Object) ([]Object, error)
|
||||
// Value returns a secret's value, or the public material of a key (PEM
|
||||
// public key) or certificate (PEM certificate). Private key material is
|
||||
// never returned for keys.
|
||||
Value(ctx context.Context, o Object) (SecretValue, error)
|
||||
}
|
||||
|
||||
// Current is implemented by providers that know the source lazysecrets
|
||||
// was started in (the current git repo) and can find it faster than
|
||||
// Sources lists everything, so it shows first.
|
||||
type Current interface {
|
||||
Current(ctx context.Context) (Source, bool)
|
||||
}
|
||||
|
||||
// Locator is implemented by providers that can find where a container lives,
|
||||
// to open links. address is a vault host (kv.vault.azure.net) or a
|
||||
// workspace host. It returns the ID of the source holding it.
|
||||
type Locator interface {
|
||||
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
|
||||
}
|
||||
|
||||
// Files is implemented by providers whose containers are files on disk
|
||||
// (SOPS and .env files in repos), so they can be opened in an editor.
|
||||
type Files interface {
|
||||
// File is where c is, and for o (nil for the whole file) the line its
|
||||
// key is on.
|
||||
File(c Container, o *Object) (File, error)
|
||||
}
|
||||
|
||||
// File is a container's file, to open.
|
||||
type File struct {
|
||||
Path string
|
||||
Line int // 1-based; 0 for the top
|
||||
Sops string // the sops command to edit it with; empty for a plain file
|
||||
}
|
||||
|
||||
// Accounts is implemented by providers that can describe the identity
|
||||
// they use, from what they have locally: for the Details pane on the
|
||||
// provider's row. ok is false when there's nothing to say.
|
||||
type Accounts interface {
|
||||
Account() (Account, bool)
|
||||
}
|
||||
|
||||
// Account describes a signed-in identity.
|
||||
type Account struct {
|
||||
Fields []Attr
|
||||
// Copy is what y copies on the provider's row, named CopyLabel.
|
||||
Copy, CopyLabel string
|
||||
}
|
||||
|
||||
// Denied is a refusal with what to check, from what the service said: a
|
||||
// role to activate in PIM, access to ask for, the network. Error is the
|
||||
// refusal, then the checks as a list.
|
||||
type Denied struct {
|
||||
Err error
|
||||
Checks []string
|
||||
}
|
||||
|
||||
func (d *Denied) Error() string {
|
||||
if len(d.Checks) == 0 {
|
||||
return d.Err.Error()
|
||||
}
|
||||
return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ")
|
||||
}
|
||||
|
||||
func (d *Denied) Unwrap() error { return d.Err }
|
||||
|
||||
// ContainerDetails is implemented by providers that can say more about a
|
||||
// container than its listing does: how a file is encrypted and what git
|
||||
// knows of it. ErrUnsupported means there's nothing more for c.
|
||||
type ContainerDetails interface {
|
||||
Details(ctx context.Context, c Container) ([]Attr, error)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Details are how a file is encrypted and for whom, and what git knows of
|
||||
// it: a plain .env file that git tracks, or could, is marked.
|
||||
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||
r, ok := p.repo(c.Source.ID)
|
||||
if !ok {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
rel, err := filepath.Rel(r.root, c.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Attr
|
||||
meta, isSops := readMeta(c.ID)
|
||||
if isSops {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||
for i, k := range meta.recipients {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "keys"
|
||||
}
|
||||
out = append(out, provider.Attr{Label: label, Value: k})
|
||||
}
|
||||
if !meta.modified.IsZero() {
|
||||
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
||||
}
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
||||
}
|
||||
|
||||
git := func(args ...string) (string, bool) {
|
||||
b, err := exec.CommandContext(ctx, "git", append([]string{"-C", r.root}, args...)...).Output()
|
||||
return strings.TrimSpace(string(b)), err == nil
|
||||
}
|
||||
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
||||
case tracked && !isSops:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
case tracked:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
|
||||
default:
|
||||
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" {
|
||||
out = append(out, provider.Attr{Label: "last commit", Value: last})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,523 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// A sops-shaped YAML file: listing reads its keys and metadata only.
|
||||
const sopsYAML = `db:
|
||||
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
port: ENC[AES256_GCM,data:x,iv:x,tag:x,type:int]
|
||||
list:
|
||||
- ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
public_unencrypted: hello
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1xxxx
|
||||
enc: x
|
||||
lastmodified: "2026-10-09T10:05:29Z"
|
||||
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
version: 3.13.3
|
||||
`
|
||||
|
||||
const sopsEnv = "API_KEY=ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]\n" +
|
||||
"sops_age__list_0__map_recipient=age1xxxx\nsops_mac=ENC[x]\nsops_lastmodified=2026-10-09T10:05:29Z\nsops_version=3.13.3\n"
|
||||
|
||||
// gitRepo makes a git repository with files, all committed, and returns
|
||||
// its root.
|
||||
func gitRepo(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root) // macOS: /var is /private/var
|
||||
for name, body := range files {
|
||||
p := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"init", "-q"}, {"add", "."},
|
||||
{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "files"},
|
||||
{"remote", "add", "origin", "git@git.example.com:team/infra.git"},
|
||||
} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v %s", args, err, out)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func names[T any](items []T, name func(T) string) []string {
|
||||
var out []string
|
||||
for _, it := range items {
|
||||
out = append(out, name(it))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRepoFilesAndKeys(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
"svc/billing/prod.env": sopsEnv,
|
||||
"svc/billing/plain.yaml": "not: sops\n",
|
||||
"README.md": "# hi\n",
|
||||
})
|
||||
p := New(Settings{Dir: filepath.Join(root, "svc", "billing")})
|
||||
ctx := context.Background()
|
||||
|
||||
srcs, err := p.Sources(ctx)
|
||||
if err != nil || len(srcs) != 1 {
|
||||
t.Fatalf("sources %v, %v", srcs, err)
|
||||
}
|
||||
src := srcs[0]
|
||||
if src.Name != filepath.Base(root) || src.Detail != "git.example.com/team/infra" || src.URL != "https://git.example.com/team/infra" {
|
||||
t.Errorf("source %+v", src)
|
||||
}
|
||||
|
||||
files, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Only sops files; those under where it started first.
|
||||
if got := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(got, []string{"svc/billing/prod.env", "secrets/app.yaml"}) {
|
||||
t.Errorf("files %v", got)
|
||||
}
|
||||
if files[1].Detail != "sops · age" {
|
||||
t.Errorf("detail %q", files[1].Detail)
|
||||
}
|
||||
|
||||
keys, err := p.Objects(ctx, files[1], provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(keys, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"db.password", "db.port", "list[0]", "public_unencrypted"}) {
|
||||
t.Errorf("keys %v", got)
|
||||
}
|
||||
if keys[0].Updated.IsZero() {
|
||||
t.Error("lastmodified not read")
|
||||
}
|
||||
if _, extract, _ := strings.Cut(keys[2].ID, "\x00"); extract != `["list"][0]` {
|
||||
t.Errorf("extract %q", extract)
|
||||
}
|
||||
|
||||
env, _ := p.Objects(ctx, files[0], provider.TypeSecret)
|
||||
if got := names(env, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"API_KEY"}) {
|
||||
t.Errorf("env keys %v", got)
|
||||
}
|
||||
|
||||
if id, ok, _ := p.Locate(ctx, "git.example.com/team/infra"); !ok || id != root {
|
||||
t.Errorf("locate: %q %v", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhichFiles(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"secrets/a.yaml": sopsYAML,
|
||||
"other/b.yaml": sopsYAML,
|
||||
"other/testdata/c.yaml": sopsYAML,
|
||||
}
|
||||
list := func(root string, s Settings) []string {
|
||||
s.Dir = root
|
||||
p := New(s)
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
cs, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(cs, func(c provider.Container) string { return c.Name })
|
||||
slices.Sort(got)
|
||||
return got
|
||||
}
|
||||
|
||||
// No rules: every tracked sops file.
|
||||
root := gitRepo(t, files)
|
||||
if got := list(root, Settings{}); len(got) != 3 {
|
||||
t.Errorf("whole repo: %v", got)
|
||||
}
|
||||
// The config's globs.
|
||||
if got := list(root, Settings{Include: []string{"**/*.yaml"}, Exclude: []string{"**/testdata/**"}}); !slices.Equal(got, []string{"other/b.yaml", "secrets/a.yaml"}) {
|
||||
t.Errorf("config globs: %v", got)
|
||||
}
|
||||
|
||||
// .sops.yaml says where sops files go.
|
||||
withRules := map[string]string{".sops.yaml": "creation_rules:\n - path_regex: secrets/.*\\.yaml$\n age: age1x\n"}
|
||||
for k, v := range files {
|
||||
withRules[k] = v
|
||||
}
|
||||
root = gitRepo(t, withRules)
|
||||
if got := list(root, Settings{}); !slices.Equal(got, []string{"secrets/a.yaml"}) {
|
||||
t.Errorf(".sops.yaml rules: %v", got)
|
||||
}
|
||||
|
||||
// The repo's own .lazysecrets.yaml wins over both.
|
||||
withRules[".lazysecrets.yaml"] = "include: [\"other/**\"]\nexclude: [\"**/testdata/**\"]\n"
|
||||
root = gitRepo(t, withRules)
|
||||
if got := list(root, Settings{Include: []string{"secrets/**"}}); !slices.Equal(got, []string{"other/b.yaml"}) {
|
||||
t.Errorf(".lazysecrets.yaml: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRemote(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://git.kebcloud.no/keb/nix-config.git": "git.kebcloud.no/keb/nix-config",
|
||||
"git@github.com:Team/Repo.git": "github.com/team/repo",
|
||||
"ssh://git@git.example.com:2222/team/infra": "git.example.com/team/infra",
|
||||
"https://user@dev.azure.com/org/proj/_git/x": "dev.azure.com/org/proj/_git/x",
|
||||
} {
|
||||
if got, _ := normalizeRemote(in); got != want {
|
||||
t.Errorf("%s: %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With sops and age installed: real encryption and decryption, with a
|
||||
// throwaway key.
|
||||
func TestDecrypt(t *testing.T) {
|
||||
for _, bin := range []string{"sops", "age-keygen"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skip("needs " + bin)
|
||||
}
|
||||
}
|
||||
dir := t.TempDir()
|
||||
keyFile := filepath.Join(dir, "key.txt")
|
||||
if out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput(); err != nil {
|
||||
t.Fatalf("age-keygen: %v %s", err, out)
|
||||
}
|
||||
b, _ := os.ReadFile(keyFile)
|
||||
var recipient string
|
||||
for _, f := range strings.Fields(string(b)) {
|
||||
if strings.HasPrefix(f, "age1") {
|
||||
recipient = f
|
||||
}
|
||||
}
|
||||
plain := filepath.Join(dir, "plain.yaml")
|
||||
os.WriteFile(plain, []byte("db:\n password: hunter2\nlist:\n - first\n"), 0o600)
|
||||
enc, err := exec.Command("sops", "encrypt", "--age", recipient, plain).Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := gitRepo(t, map[string]string{"secrets.yaml": string(enc)})
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", keyFile)
|
||||
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
cs, _ := p.Containers(ctx, srcs[0])
|
||||
keys, _ := p.Objects(ctx, cs[0], provider.TypeSecret)
|
||||
for _, k := range keys {
|
||||
v, err := p.Value(ctx, k)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", k.Name, err)
|
||||
}
|
||||
want := map[string]string{"db.password": "hunter2", "list[0]": "first"}[k.Name]
|
||||
if v.Reveal() != want {
|
||||
t.Errorf("%s = %q, want %q", k.Name, v.Reveal(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// From a link, without the listing: the extract path from the name.
|
||||
v, err := p.Value(ctx, provider.Object{Container: cs[0], Name: "list[0]"})
|
||||
if err != nil || v.Reveal() != "first" {
|
||||
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||
}
|
||||
|
||||
// Without the key: sops' own error.
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
|
||||
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
|
||||
t.Errorf("no key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWithoutSecretsIsntListed(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{"README.md": "# no secrets\n", "config.yaml": "plain: yaml\n"})
|
||||
srcs, err := New(Settings{Dir: root}).Sources(context.Background())
|
||||
if err != nil || len(srcs) != 0 {
|
||||
t.Errorf("sources %v, %v", srcs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainEnvFiles(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n.env.local\n",
|
||||
".env.example": "TOKEN=changeme\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
})
|
||||
// Ignored, as they usually are: written after the commit.
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nexport TOKEN=\"s3cr3t value\"\nURL=https://x.example # the API\nQUOTED='a#b'\n"), 0o600)
|
||||
os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "node_modules", "pkg", ".env"), []byte("X=1\n"), 0o600)
|
||||
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
files, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, c := range files {
|
||||
got[c.Name] = c.Detail
|
||||
}
|
||||
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
|
||||
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
|
||||
}
|
||||
|
||||
var env provider.Container
|
||||
for _, c := range files {
|
||||
if c.Name == ".env" {
|
||||
env = c
|
||||
}
|
||||
}
|
||||
keys, _ := p.Objects(ctx, env, provider.TypeSecret)
|
||||
want := map[string]string{"TOKEN": "s3cr3t value", "URL": "https://x.example", "QUOTED": "a#b"}
|
||||
if len(keys) != len(want) {
|
||||
t.Fatalf("keys %v", names(keys, func(o provider.Object) string { return o.Name }))
|
||||
}
|
||||
for _, k := range keys {
|
||||
v, err := p.Value(ctx, k) // no sops involved
|
||||
if err != nil || v.Reveal() != want[k.Name] {
|
||||
t.Errorf("%s = %q, %v; want %q", k.Name, v.Reveal(), err, want[k.Name])
|
||||
}
|
||||
}
|
||||
|
||||
// .sops.yaml rules limit SOPS files, not plain env files.
|
||||
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
|
||||
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
|
||||
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
|
||||
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
|
||||
t.Errorf("with rules elsewhere: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoots(t *testing.T) {
|
||||
withSecrets := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
without := gitRepo(t, map[string]string{"README.md": "# none\n"})
|
||||
nested := gitRepo(t, map[string]string{".env.example": "X=y\n", "prod.env": sopsEnv})
|
||||
|
||||
// A folder of repos: two at the top, one under an org folder.
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.Rename(withSecrets, filepath.Join(root, "infra"))
|
||||
os.Rename(without, filepath.Join(root, "docs"))
|
||||
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
|
||||
os.Rename(nested, filepath.Join(root, "acme", "billing"))
|
||||
|
||||
outside := t.TempDir() // not in a repo
|
||||
srcs, err := New(Settings{Dir: outside, Roots: []string{root}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.Name })
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
|
||||
t.Errorf("repos under the root: %v (want the two with secret files)", got)
|
||||
}
|
||||
|
||||
// Inside a repo: the same repos, that one first and marked Here.
|
||||
srcs, _ = New(Settings{Dir: filepath.Join(root, "acme", "billing"), Roots: []string{root}}).Sources(context.Background())
|
||||
got = names(srcs, func(s provider.Source) string { return s.Name })
|
||||
if !slices.Equal(got, []string{"billing", "infra"}) || !srcs[0].Here || srcs[1].Here {
|
||||
t.Errorf("inside a repo: %v, want billing (here) then infra", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarredReposAlongside(t *testing.T) {
|
||||
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
|
||||
other := gitRepo(t, map[string]string{"secrets/b.yaml": sopsYAML})
|
||||
_ = other
|
||||
|
||||
srcs, err := New(Settings{Dir: here, Starred: []string{starred, here}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.ID })
|
||||
if !slices.Equal(got, []string{here, starred}) {
|
||||
t.Errorf("sources %v, want this repo then the starred one (not under a root)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sops' output when no key opens the file, as sops 3.13 prints it.
|
||||
const noKeyStderr = `Failed to get the data key required to decrypt the SOPS file.
|
||||
|
||||
Group 0: FAILED
|
||||
age1el8zh94re2488t5rrpqxk5xcxhs2cg6xfcccntz6l4ggv0rsf9yq44c0cs: FAILED
|
||||
- | failed to create reader for decrypting sops data key with
|
||||
| age: no identity matched any of the recipients. Did not find
|
||||
| keys in locations 'SOPS_AGE_SSH_PRIVATE_KEY_FILE'.
|
||||
https://kv-app.vault.azure.net/keys/sops/abc: FAILED
|
||||
- | failed to decrypt sops data key with Azure Key Vault key
|
||||
| 'https://kv-app.vault.azure.net/keys/sops/abc': DefaultAzureCredential: failed to acquire a token.
|
||||
|
||||
Recovery failed because no master key was able to decrypt the file. In
|
||||
order for SOPS to recover the file, at least one key has to be successful,
|
||||
but none were.
|
||||
`
|
||||
|
||||
func TestSopsError(t *testing.T) {
|
||||
got := sopsError(noKeyStderr, errors.New("exit status 128"))
|
||||
if got != "sops couldn't decrypt it:\n"+strings.TrimSpace(noKeyStderr) {
|
||||
t.Errorf("not sops' whole message:\n%s", got)
|
||||
}
|
||||
if got := sopsError("", errors.New("exit status 1")); got != "sops couldn't decrypt it:\nexit status 1" {
|
||||
t.Errorf("without output: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(s string) string {
|
||||
p := filepath.Join(dir, "config")
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
return p
|
||||
}
|
||||
for _, c := range []struct {
|
||||
config, url string
|
||||
ok bool
|
||||
}{
|
||||
{"[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@git.example.com:team/infra.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", "git@git.example.com:team/infra.git", true},
|
||||
{"[remote \"upstream\"]\n\turl = https://a/b\n[remote \"origin\"]\n\turl=https://git.example.com/x.git\n", "https://git.example.com/x.git", true},
|
||||
{"[core]\n\tbare = false\n", "", true}, // no origin
|
||||
{"[url \"git@host:\"]\n\tinsteadOf = https://host/\n[remote \"origin\"]\n\turl = https://host/x\n", "", false},
|
||||
} {
|
||||
url, ok := originURL(write(c.config))
|
||||
if url != c.url || ok != c.ok {
|
||||
t.Errorf("%q: %q %v, want %q %v", c.config, url, ok, c.url, c.ok)
|
||||
}
|
||||
}
|
||||
if _, ok := originURL(filepath.Join(dir, "missing")); ok {
|
||||
t.Error("a missing config should leave it to git")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvFilesSkipPackages(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"app/main.go": "package main\n",
|
||||
"vendor/lib/x.go": "package lib\n",
|
||||
"web/node_modules/pkg/index.js": "x\n",
|
||||
"secrets/sops.yaml": sopsYAML,
|
||||
"web/node_modules/pkg/sops.yaml": sopsYAML,
|
||||
})
|
||||
// Untracked, as .env files usually are: next to tracked code, in a
|
||||
// package folder, and in a folder with nothing tracked.
|
||||
for f, body := range map[string]string{
|
||||
"app/.env": "A=1\n",
|
||||
"web/node_modules/pkg/.env": "B=2\n",
|
||||
"vendor/lib/.env": "C=3\n",
|
||||
"scratch/.env": "D=4\n",
|
||||
} {
|
||||
os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755)
|
||||
os.WriteFile(filepath.Join(root, f), []byte(body), 0o644)
|
||||
}
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
files, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(files, func(c provider.Container) string { return c.Name })
|
||||
slices.Sort(got)
|
||||
if want := []string{"app/.env", "secrets/sops.yaml"}; !slices.Equal(got, want) {
|
||||
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
})
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nTOKEN=x\n\nURL=y\n"), 0o600)
|
||||
p := New(Settings{Dir: root, Sops: "/opt/sops"})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
files, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := map[string]int{}
|
||||
for _, c := range files {
|
||||
f, err := p.File(c, nil)
|
||||
if err != nil || f.Path != filepath.Join(root, c.Name) || f.Line != 0 {
|
||||
t.Errorf("%s: %+v, %v", c.Name, f, err)
|
||||
}
|
||||
if sops := f.Sops != ""; sops != (c.Name == "secrets/app.yaml") || (sops && f.Sops != "/opt/sops") {
|
||||
t.Errorf("%s: sops %q", c.Name, f.Sops)
|
||||
}
|
||||
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
||||
for _, k := range keys {
|
||||
f, err := p.File(c, &k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines[c.Name+" "+k.Name] = f.Line
|
||||
}
|
||||
}
|
||||
want := map[string]int{
|
||||
".env TOKEN": 2, ".env URL": 4,
|
||||
"secrets/app.yaml db.password": 2, "secrets/app.yaml db.port": 3,
|
||||
"secrets/app.yaml list[0]": 5, "secrets/app.yaml public_unencrypted": 6,
|
||||
}
|
||||
if !maps.Equal(lines, want) {
|
||||
t.Errorf("lines %v\nwant %v", lines, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDetails(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
"prod.env": sopsEnv,
|
||||
"tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look
|
||||
})
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600)
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
details := func(name string) map[string]provider.Attr {
|
||||
t.Helper()
|
||||
attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Attr{}
|
||||
for _, a := range attrs {
|
||||
out[a.Label] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
d := details("secrets/app.yaml")
|
||||
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
|
||||
!strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" {
|
||||
t.Errorf("sops yaml: %+v", d)
|
||||
}
|
||||
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
|
||||
t.Errorf("sops env: %+v", d)
|
||||
}
|
||||
if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn {
|
||||
t.Errorf("tracked plain env: %+v", d)
|
||||
}
|
||||
if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn {
|
||||
t.Errorf("ignored env: %+v", d)
|
||||
}
|
||||
if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") {
|
||||
t.Errorf("untracked env: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
const masked = "••••••"
|
||||
|
||||
// SecretValue holds a secret's plaintext. Every formatting, logging and
|
||||
// encoding path renders it masked; the plaintext is only available through
|
||||
// Reveal, so it cannot leak through a stray Printf, slog call or error.
|
||||
type SecretValue struct {
|
||||
b []byte
|
||||
}
|
||||
|
||||
func NewSecretValue(b []byte) SecretValue {
|
||||
return SecretValue{b: append([]byte(nil), b...)}
|
||||
}
|
||||
|
||||
// Reveal returns the plaintext. Call it only to display or copy the value.
|
||||
func (v SecretValue) Reveal() string { return string(v.b) }
|
||||
|
||||
func (v SecretValue) Len() int { return len(v.b) }
|
||||
|
||||
func (v SecretValue) String() string { return masked }
|
||||
func (v SecretValue) GoString() string { return masked }
|
||||
|
||||
// Format covers every fmt verb, including %#v and %x.
|
||||
func (v SecretValue) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(masked)) }
|
||||
|
||||
func (v SecretValue) LogValue() slog.Value { return slog.StringValue(masked) }
|
||||
|
||||
func (v SecretValue) MarshalText() ([]byte, error) { return []byte(masked), nil }
|
||||
@@ -0,0 +1,51 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecretValueNeverFormatsPlaintext(t *testing.T) {
|
||||
const plain = "hunter2"
|
||||
v := NewSecretValue([]byte(plain))
|
||||
wrapped := struct{ V SecretValue }{v}
|
||||
|
||||
var outputs []string
|
||||
for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d"} {
|
||||
outputs = append(outputs, fmt.Sprintf(verb, v), fmt.Sprintf(verb, wrapped))
|
||||
}
|
||||
outputs = append(outputs, fmt.Sprint(v), fmt.Errorf("failed: %v", v).Error())
|
||||
|
||||
js, err := json.Marshal(wrapped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputs = append(outputs, string(js))
|
||||
|
||||
var buf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&buf, nil)).Info("x", "value", v)
|
||||
slog.New(slog.NewTextHandler(&buf, nil)).Info("x", "value", v)
|
||||
outputs = append(outputs, buf.String())
|
||||
|
||||
for _, out := range outputs {
|
||||
if strings.Contains(out, plain) || strings.Contains(out, fmt.Sprintf("%x", plain)) {
|
||||
t.Errorf("plaintext leaked: %q", out)
|
||||
}
|
||||
}
|
||||
if v.Reveal() != plain {
|
||||
t.Errorf("Reveal() = %q, want %q", v.Reveal(), plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSecretValueCopiesInput(t *testing.T) {
|
||||
b := []byte("abc")
|
||||
v := NewSecretValue(b)
|
||||
b[0] = 'x'
|
||||
if v.Reveal() != "abc" {
|
||||
t.Errorf("value aliased caller's slice: %q", v.Reveal())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user