Merge remote-tracking branch 'origin/feat/container-details' into feat/l-for-details
# Conflicts: # internal/ui/app/app_test.go
This commit is contained in:
commit
75f62240fc
15 files changed
+559
-8
No files matched your search
@@ -64,3 +64,9 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
|
|||||||
key (PEM public key) or certificate; private key material is never
|
key (PEM public key) or certificate; private key material is never
|
||||||
fetched. `v` lists an item's versions. Items expiring within
|
fetched. `v` lists an item's versions. Items expiring within
|
||||||
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||||
|
|
||||||
|
On a vault, the right pane shows its settings: resource group, location,
|
||||||
|
SKU, whether access is by Azure roles or access policies, soft delete and
|
||||||
|
purge protection, which networks can reach it, private endpoints and tags.
|
||||||
|
They come with the list of vaults, so they cost no extra requests and are
|
||||||
|
cached with it.
|
||||||
@@ -13,6 +13,10 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if
|
|||||||
Databricks refuses, lazysecrets reads the backing vault directly with your
|
Databricks refuses, lazysecrets reads the backing vault directly with your
|
||||||
az login (which then needs the [Key Vault roles](azure.md#permissions)).
|
az login (which then needs the [Key Vault roles](azure.md#permissions)).
|
||||||
|
|
||||||
|
On a scope, the right pane shows who has access to it and how much
|
||||||
|
(manage, write, read). Databricks shows that only to the scope's
|
||||||
|
managers; to others it says so.
|
||||||
|
|
||||||
Hide profiles you don't want in the [config](config.md):
|
Hide profiles you don't want in the [config](config.md):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ copying or `lazysecrets get` decrypts one value with your `sops` binary, so
|
|||||||
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
|
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
|
||||||
KMS, PGP). When sops can't decrypt, its whole error message is shown.
|
KMS, PGP). When sops can't decrypt, its whole error message is shown.
|
||||||
|
|
||||||
|
On a file, the right pane shows how it's encrypted and the keys it's
|
||||||
|
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
||||||
|
fingerprints), when it was last encrypted, and what git knows of it:
|
||||||
|
tracked, ignored or neither, and its last commit. A plain `.env` file that
|
||||||
|
git tracks, or that isn't ignored, is marked in red: its values are in the
|
||||||
|
repo's history, or easily could be.
|
||||||
|
|
||||||
## Opening files
|
## Opening files
|
||||||
|
|
||||||
`e` opens the selected file, or on a secret, its file at that key's line:
|
`e` opens the selected file, or on a secret, its file at that key's line:
|
||||||
|
|||||||
@@ -185,10 +185,6 @@ func (p *Provider) tenant(subscriptionID string) string {
|
|||||||
return p.tenants[subscriptionID]
|
return p.tenants[subscriptionID]
|
||||||
}
|
}
|
||||||
|
|
||||||
const vaultQuery = `resources
|
|
||||||
| where type =~ 'microsoft.keyvault/vaults'
|
|
||||||
| project id, name, uri = tostring(properties.vaultUri)`
|
|
||||||
|
|
||||||
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||||
var out []provider.Container
|
var out []provider.Container
|
||||||
req := armresourcegraph.QueryRequest{
|
req := armresourcegraph.QueryRequest{
|
||||||
@@ -228,7 +224,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) {
|
|||||||
if id == "" || name == "" || uri == "" {
|
if id == "" || name == "" || uri == "" {
|
||||||
return provider.Container{}, false
|
return provider.Container{}, false
|
||||||
}
|
}
|
||||||
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
|
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) {
|
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||||
@@ -249,7 +245,7 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
|
|||||||
}
|
}
|
||||||
out = append(out, provider.Container{
|
out = append(out, provider.Container{
|
||||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||||
Detail: *v.Properties.VaultURI, Types: objectTypes,
|
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -417,3 +418,49 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
|
|||||||
size := (len(b) - 1) / 2
|
size := (len(b) - 1) / 2
|
||||||
return b[1 : 1+size], b[1+size:]
|
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,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
|
||||||
|
}
|
||||||
@@ -140,6 +140,13 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
|||||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||||
map[string]any{"key": "client-secret"},
|
map[string]any{"key": "client-secret"},
|
||||||
}})
|
}})
|
||||||
|
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "app":
|
||||||
|
write(w, 200, map[string]any{"items": []any{
|
||||||
|
map[string]any{"principal": "users", "permission": "READ"},
|
||||||
|
map[string]any{"principal": "admins", "permission": "MANAGE"},
|
||||||
|
}})
|
||||||
|
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "locked":
|
||||||
|
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have MANAGE permission"})
|
||||||
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
||||||
@@ -291,3 +298,20 @@ func TestClassify(t *testing.T) {
|
|||||||
t.Error("cancellation classified as auth")
|
t.Error("cancellation classified as auth")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScopeDetails(t *testing.T) {
|
||||||
|
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||||
|
src := provider.Source{ID: "prod"}
|
||||||
|
attrs, err := p.Details(context.Background(), provider.Container{Source: src, Name: "app"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []provider.Attr{{Label: "access", Value: "admins · manage"}, {Value: "users · read"}}
|
||||||
|
if !reflect.DeepEqual(attrs, want) {
|
||||||
|
t.Errorf("app: %+v", attrs)
|
||||||
|
}
|
||||||
|
attrs, err = p.Details(context.Background(), provider.Container{Source: src, Name: "locked"})
|
||||||
|
if err != nil || len(attrs) != 1 || !strings.Contains(attrs[0].Value, "managers") {
|
||||||
|
t.Errorf("locked: %+v, %v", attrs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package databricks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/databricks/databricks-sdk-go/apierr"
|
||||||
|
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Details are who has access to a scope, and how much. Only the scope's
|
||||||
|
// managers may see that; to others it says so.
|
||||||
|
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||||
|
w, pr, err := p.client(c.Source.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
acls, err := w.ListAclsAll(ctx, workspace.ListAclsRequest{Scope: c.Name})
|
||||||
|
var api *apierr.APIError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &api) && api.StatusCode == 403:
|
||||||
|
return []provider.Attr{{Label: "access", Value: "only the scope's managers can see who has it"}}, nil
|
||||||
|
case err != nil:
|
||||||
|
return nil, classify(pr, err)
|
||||||
|
case len(acls) == 0:
|
||||||
|
return []provider.Attr{{Label: "access", Value: "workspace admins only"}}, nil
|
||||||
|
}
|
||||||
|
rank := map[workspace.AclPermission]int{workspace.AclPermissionManage: 0, workspace.AclPermissionWrite: 1, workspace.AclPermissionRead: 2}
|
||||||
|
slices.SortFunc(acls, func(a, b workspace.AclItem) int {
|
||||||
|
return cmp.Or(cmp.Compare(rank[a.Permission], rank[b.Permission]), cmp.Compare(a.Principal, b.Principal))
|
||||||
|
})
|
||||||
|
out := make([]provider.Attr, len(acls))
|
||||||
|
for i, a := range acls {
|
||||||
|
out[i] = provider.Attr{Value: a.Principal + " · " + strings.ToLower(string(a.Permission))}
|
||||||
|
}
|
||||||
|
out[0].Label = "access"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -93,6 +93,8 @@ type Container struct {
|
|||||||
// secrets only.
|
// secrets only.
|
||||||
Types []ObjectType
|
Types []ObjectType
|
||||||
URL string // web page for the container, if any
|
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.
|
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||||
@@ -107,6 +109,7 @@ func (c Container) ObjectTypes() []ObjectType {
|
|||||||
type Attr struct {
|
type Attr struct {
|
||||||
Label string
|
Label string
|
||||||
Value 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.
|
// Object describes a secret, key or certificate without its value.
|
||||||
@@ -199,6 +202,14 @@ type Account struct {
|
|||||||
Copy, CopyLabel string
|
Copy, CopyLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContainerDetails is implemented by providers that can say more about a
|
||||||
|
// container than its listing does: a vault's settings, who has access to
|
||||||
|
// a scope, how a file is encrypted and what git knows of it. It may go
|
||||||
|
// over the network; ErrUnsupported means there's nothing more for c.
|
||||||
|
type ContainerDetails interface {
|
||||||
|
Details(ctx context.Context, c Container) ([]Attr, error)
|
||||||
|
}
|
||||||
|
|
||||||
// AccountGroups is implemented by providers that can list the groups
|
// AccountGroups is implemented by providers that can list the groups
|
||||||
// their account is a member of, directly or through other groups. It
|
// their account is a member of, directly or through other groups. It
|
||||||
// goes over the network.
|
// goes over the network.
|
||||||
|
|||||||
@@ -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 · %an: %s", "--", rel); ok && last != "" {
|
||||||
|
out = append(out, provider.Attr{Label: "last commit", Value: last})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -815,8 +815,30 @@ func anyMatch(res []*regexp.Regexp, f string) bool {
|
|||||||
// --- sops files ---
|
// --- sops files ---
|
||||||
|
|
||||||
type sopsMeta struct {
|
type sopsMeta struct {
|
||||||
methods []string // age, azure_kv, …
|
methods []string // age, azure_kv, …
|
||||||
modified time.Time
|
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
|
||||||
|
modified time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// recipient names a key a file is encrypted for, from its sops metadata:
|
||||||
|
// public, never a secret.
|
||||||
|
func recipient(method string, f map[string]string) string {
|
||||||
|
var v string
|
||||||
|
switch method {
|
||||||
|
case "age":
|
||||||
|
v = f["recipient"]
|
||||||
|
case "pgp":
|
||||||
|
v = f["fp"]
|
||||||
|
case "kms":
|
||||||
|
v = f["arn"]
|
||||||
|
case "gcp_kms":
|
||||||
|
v = f["resource_id"]
|
||||||
|
case "azure_kv":
|
||||||
|
v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"]
|
||||||
|
case "hc_vault":
|
||||||
|
v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"]
|
||||||
|
}
|
||||||
|
return method + " " + v
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxFileSize = 4 << 20
|
const maxFileSize = 4 << 20
|
||||||
@@ -848,6 +870,15 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
for _, k := range known {
|
for _, k := range known {
|
||||||
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
||||||
m.methods = append(m.methods, k)
|
m.methods = append(m.methods, k)
|
||||||
|
for _, e := range v {
|
||||||
|
fields := map[string]string{}
|
||||||
|
if em, ok := e.(map[string]any); ok {
|
||||||
|
for fk, fv := range em {
|
||||||
|
fields[fk] = fmt.Sprint(fv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.recipients = append(m.recipients, recipient(k, fields))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s, ok := doc.Sops["lastmodified"].(string); ok {
|
if s, ok := doc.Sops["lastmodified"].(string); ok {
|
||||||
@@ -864,6 +895,7 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
for _, k := range known {
|
for _, k := range known {
|
||||||
if strings.Contains(text, "sops_"+k+"__") {
|
if strings.Contains(text, "sops_"+k+"__") {
|
||||||
m.methods = append(m.methods, k)
|
m.methods = append(m.methods, k)
|
||||||
|
m.recipients = append(m.recipients, envRecipients(text, k)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
||||||
@@ -881,6 +913,35 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
return m, true
|
return m, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envRecipients are method's keys in a dotenv or ini sops file, whose
|
||||||
|
// metadata is flattened: sops_age__list_0__map_recipient=age1….
|
||||||
|
func envRecipients(text, method string) []string {
|
||||||
|
prefix := "sops_" + method + "__list_"
|
||||||
|
var order []string
|
||||||
|
keys := map[string]map[string]string{}
|
||||||
|
for line := range strings.Lines(text) {
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix)
|
||||||
|
if !ok || !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, field, ok := strings.Cut(rest, "__map_")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keys[n] == nil {
|
||||||
|
keys[n] = map[string]string{}
|
||||||
|
order = append(order, n)
|
||||||
|
}
|
||||||
|
keys[n][field] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
out := make([]string, len(order))
|
||||||
|
for i, n := range order {
|
||||||
|
out[i] = recipient(method, keys[n])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func formatOf(file string) string {
|
func formatOf(file string) string {
|
||||||
switch strings.ToLower(filepath.Ext(file)) {
|
switch strings.ToLower(filepath.Ext(file)) {
|
||||||
case ".yaml", ".yml":
|
case ".yaml", ".yml":
|
||||||
|
|||||||
@@ -477,3 +477,47 @@ func TestFile(t *testing.T) {
|
|||||||
t.Errorf("lines %v\nwant %v", 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.Contains(d["last commit"].Value, "t: files") || 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ const (
|
|||||||
slotDescribe // extra metadata for the selected object
|
slotDescribe // extra metadata for the selected object
|
||||||
slotCopy // fetch a value straight to the clipboard
|
slotCopy // fetch a value straight to the clipboard
|
||||||
slotVersions // count the selected object's versions
|
slotVersions // count the selected object's versions
|
||||||
|
slotDetails // a vault's, scope's or file's details
|
||||||
slotCount
|
slotCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,6 +178,7 @@ type Model struct {
|
|||||||
fields panes.Fields
|
fields panes.Fields
|
||||||
lastLeft pane
|
lastLeft pane
|
||||||
groups map[string]groupsState
|
groups map[string]groupsState
|
||||||
|
details map[string]detailsState // by containerKey
|
||||||
getenv func(string) string
|
getenv func(string) string
|
||||||
nvimOpen func(server, path string, line int) error // edit.go
|
nvimOpen func(server, path string, line int) error // edit.go
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
@@ -376,6 +378,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
nvimOpen: nvimOpen,
|
nvimOpen: nvimOpen,
|
||||||
cache: newCache(),
|
cache: newCache(),
|
||||||
groups: map[string]groupsState{},
|
groups: map[string]groupsState{},
|
||||||
|
details: map[string]detailsState{},
|
||||||
fields: panes.NewFields(),
|
fields: panes.NewFields(),
|
||||||
}
|
}
|
||||||
if m.clip == nil {
|
if m.clip == nil {
|
||||||
@@ -590,6 +593,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case groupsMsg:
|
case groupsMsg:
|
||||||
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
|
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
|
||||||
|
|
||||||
|
case detailsMsg:
|
||||||
|
if msg.req == m.reqID[slotDetails] {
|
||||||
|
m.done(slotDetails)
|
||||||
|
}
|
||||||
|
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
||||||
|
delete(m.details, msg.key) // tried again when shown again
|
||||||
|
} else {
|
||||||
|
m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err}
|
||||||
|
}
|
||||||
|
|
||||||
case crawlMsg:
|
case crawlMsg:
|
||||||
cmds = append(cmds, m.handleCrawl(msg))
|
cmds = append(cmds, m.handleCrawl(msg))
|
||||||
|
|
||||||
@@ -1036,6 +1049,7 @@ func (m *Model) refresh() tea.Cmd {
|
|||||||
case paneContainers:
|
case paneContainers:
|
||||||
if src, ok := m.selectedSource(); ok {
|
if src, ok := m.selectedSource(); ok {
|
||||||
m.containers.Refresh()
|
m.containers.Refresh()
|
||||||
|
m.details = map[string]detailsState{}
|
||||||
return m.loadContainers(src, m.selSource)
|
return m.loadContainers(src, m.selSource)
|
||||||
}
|
}
|
||||||
case paneSecrets:
|
case paneSecrets:
|
||||||
|
|||||||
@@ -1269,5 +1269,22 @@ func TestGetStarted(t *testing.T) {
|
|||||||
d.send(nil)
|
d.send(nil)
|
||||||
if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") {
|
if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") {
|
||||||
t.Errorf("empty Databricks:\n%s", scr)
|
t.Errorf("empty Databricks:\n%s", scr)
|
||||||
|
|
||||||
|
// detailed is an account whose vaults have details.
|
||||||
|
type detailed struct{ provider.Provider }
|
||||||
|
|
||||||
|
func (detailed) Details(_ context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||||
|
return []provider.Attr{{Label: "access", Value: "Azure roles (RBAC)"}, {Label: "git", Value: "tracked", Warn: true}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerDetails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := newDriverOpts(t, Options{}, detailed{fake.Azure(0)})
|
||||||
|
d.keys("2")
|
||||||
|
if scr := d.screen(); !strings.Contains(scr, "access Azure roles (RBAC)") || !strings.Contains(scr, "URI") {
|
||||||
|
t.Errorf("details:\n%s", scr)
|
||||||
|
}
|
||||||
|
if st := d.m.details[d.m.containers.SelectedID()]; st.loading || len(st.attrs) != 2 {
|
||||||
|
t.Errorf("state %+v", st)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,22 @@ type groupsMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A vault's, scope's or file's details beyond its listing load once a
|
||||||
|
// session, when the cursor rests on it, and again with refresh. They're
|
||||||
|
// extra: moving on cancels the load, and search never waits for them.
|
||||||
|
type detailsState struct {
|
||||||
|
attrs []provider.Attr
|
||||||
|
err error
|
||||||
|
loading bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type detailsMsg struct {
|
||||||
|
req int
|
||||||
|
key string
|
||||||
|
attrs []provider.Attr
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// detailOf is the pane the fourth pane describes: the one in focus, or,
|
// detailOf is the pane the fourth pane describes: the one in focus, or,
|
||||||
// with the fourth in focus, the one it was entered from.
|
// with the fourth in focus, the one it was entered from.
|
||||||
func (m Model) detailOf() pane {
|
func (m Model) detailOf() pane {
|
||||||
@@ -70,7 +86,9 @@ func (m *Model) syncFields() tea.Cmd {
|
|||||||
m.fields.Show("lazysecrets", m.statusFields())
|
m.fields.Show("lazysecrets", m.statusFields())
|
||||||
case paneContainers:
|
case paneContainers:
|
||||||
if c, ok := m.containers.Selected(); ok {
|
if c, ok := m.containers.Selected(); ok {
|
||||||
|
cmd := m.loadDetails(c)
|
||||||
m.fields.Show(c.Name, m.containerFields(c))
|
m.fields.Show(c.Name, m.containerFields(c))
|
||||||
|
return cmd
|
||||||
} else {
|
} else {
|
||||||
m.fields.Show("", nil)
|
m.fields.Show("", nil)
|
||||||
}
|
}
|
||||||
@@ -177,9 +195,48 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
|
|||||||
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, a := range c.Attrs {
|
||||||
|
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
|
||||||
|
}
|
||||||
|
switch st := m.details[containerKey(c)]; {
|
||||||
|
case st.loading:
|
||||||
|
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
|
||||||
|
case errors.Is(st.err, provider.ErrUnsupported):
|
||||||
|
case st.err != nil:
|
||||||
|
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
|
||||||
|
}
|
||||||
|
for _, a := range m.details[containerKey(c)].attrs {
|
||||||
|
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadDetails loads c's details, if its provider has them and they
|
||||||
|
// aren't loaded yet.
|
||||||
|
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
|
||||||
|
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
|
||||||
|
key := containerKey(c)
|
||||||
|
if _, seen := m.details[key]; !ok || seen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// One at a time: the previous one, if it's still loading, is
|
||||||
|
// cancelled and forgotten, to load when it's shown again.
|
||||||
|
for k, st := range m.details {
|
||||||
|
if st.loading {
|
||||||
|
delete(m.details, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.details[key] = detailsState{loading: true}
|
||||||
|
ctx, req := m.begin(slotDetails)
|
||||||
|
return m.settled(ctx, func() tea.Msg {
|
||||||
|
attrs, err := d.Details(ctx, c)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
err = ctx.Err()
|
||||||
|
}
|
||||||
|
return detailsMsg{req: req, key: key, attrs: attrs, err: err}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// problemFields show why an account has nothing to show, wrapped to the
|
// problemFields show why an account has nothing to show, wrapped to the
|
||||||
// pane, in red.
|
// pane, in red.
|
||||||
func (m Model) problemFields(problem string) []panes.Field {
|
func (m Model) problemFields(problem string) []panes.Field {
|
||||||
|
|||||||
Reference in new issue
Block a user