Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link. **Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file). - **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference. - **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels. - **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening. - **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them. - **The command line:** `lazysecrets share [--key <secret>] <env files>`. **Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`. 1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`. 2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore. 3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written. 4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged. **Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable. **Safety:** - A link can only write env files, inside the folder: never in `.git`, never through a symlink. - New files are created with mode 0600. - Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB. **Along the way:** - Opening a link to a secret ends on its details, as `enter` would. - `o` on an Azure secret opens its portal page, and in the versions view, that version's page. - The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address. - `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning. - `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there. - New dependency: `filippo.io/age`. Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #54
493 lines
18 KiB
Go
493 lines
18 KiB
Go
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"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
|
|
|
"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 TestSecretPages(t *testing.T) {
|
|
c := provider.Container{Detail: "https://kv-app.vault.azure.net/"}
|
|
base := "https://portal.azure.com/#@contoso.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Secret/https://kv-app.vault.azure.net/secrets/db"
|
|
for id, want := range map[string]string{
|
|
"https://kv-app.vault.azure.net/secrets/db": base, // listed: the secret's page
|
|
"https://kv-app.vault.azure.net/secrets/db/06140fa2b9a64869a96ef040f6d4dcab": base + "/06140fa2b9a64869a96ef040f6d4dcab", // a version's
|
|
} {
|
|
sid := azsecrets.ID(id)
|
|
o := secretObject(c, &azsecrets.SecretProperties{ID: &sid}, "contoso.onmicrosoft.com")
|
|
if o.URL != want {
|
|
t.Errorf("%s: %q, want %q", id, o.URL, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|