feat: share env files as encrypted links
S → f on a repo, an env file or a key in one copies a lazysecrets://share link holding the env files, encrypted (age) with a secret's value: whoever can read that secret can open it. The key is share.key in the repo's .lazysecrets.yaml, else in the config, else picked when sharing; on Azure the link pins its version. Opening one says what it holds, decrypts when asked, and saves the files in the clone of the repo (or a folder chosen), replacing existing ones only when asked. lazysecrets share prints a link from the command line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
26 files changed
+1541
-98
No files matched your search
@@ -0,0 +1,103 @@
|
||||
package ref
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Read reads what r points at without listing its vault: the vault and
|
||||
// name are enough for Azure, tried with each account (account, the one
|
||||
// that last read it, first, in source); for Databricks and repos the
|
||||
// profile or repo is found by its host. It also returns the version read,
|
||||
// when the provider tells (provider.Versioned).
|
||||
func Read(ctx context.Context, providers []provider.Provider, r Ref, account, source string) (provider.SecretValue, string, error) {
|
||||
if r.IsShare() || r.IsSource() || r.IsContainer() {
|
||||
what := "vault or scope"
|
||||
switch {
|
||||
case r.IsShare():
|
||||
what = "share link"
|
||||
case r.IsSource():
|
||||
what = "subscription or workspace"
|
||||
}
|
||||
return provider.SecretValue{}, "", fmt.Errorf("%s points at a %s, not a secret", r, what)
|
||||
}
|
||||
if r.Kind != provider.KindAzure && r.Host == "" {
|
||||
return provider.SecretValue{}, "", errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
|
||||
}
|
||||
|
||||
type try struct {
|
||||
p provider.Provider
|
||||
o provider.Object
|
||||
}
|
||||
var tries []try
|
||||
for _, p := range providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
||||
if r.Kind != provider.KindAzure { // by host: workspaces, repos
|
||||
loc, ok := p.(provider.Locator)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
profile, found, err := loc.Locate(ctx, r.Host)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
|
||||
o.Container = provider.Container{Source: src, Name: r.Scope}
|
||||
// Listing the scopes tells a Key Vault-backed one, whose value
|
||||
// can then be read from the vault if Databricks refuses.
|
||||
if cs, err := p.Containers(ctx, src); err == nil {
|
||||
for _, c := range cs {
|
||||
if c.Name == r.Scope {
|
||||
c.Source = src
|
||||
o.Container = c
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: source}
|
||||
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
||||
}
|
||||
t := try{p, o}
|
||||
if p.ID() == account {
|
||||
tries = append([]try{t}, tries...)
|
||||
} else {
|
||||
tries = append(tries, t)
|
||||
}
|
||||
}
|
||||
if len(tries) == 0 {
|
||||
switch r.Kind {
|
||||
case provider.KindDatabricks:
|
||||
return provider.SecretValue{}, "", fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
||||
case provider.KindRepo:
|
||||
return provider.SecretValue{}, "", fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
|
||||
}
|
||||
return provider.SecretValue{}, "", fmt.Errorf("no %s account to read %s with", r.Kind, r)
|
||||
}
|
||||
var errs []error
|
||||
for _, t := range tries {
|
||||
v, version, err := value(ctx, t.p, t.o)
|
||||
if err == nil {
|
||||
return v, version, nil
|
||||
}
|
||||
if len(tries) == 1 || ctx.Err() != nil {
|
||||
return provider.SecretValue{}, "", err
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
||||
}
|
||||
return provider.SecretValue{}, "", errors.Join(errs...)
|
||||
}
|
||||
|
||||
func value(ctx context.Context, p provider.Provider, o provider.Object) (provider.SecretValue, string, error) {
|
||||
if vp, ok := p.(provider.Versioned); ok {
|
||||
return vp.VersionedValue(ctx, o)
|
||||
}
|
||||
v, err := p.Value(ctx, o)
|
||||
return v, o.Version, err
|
||||
}
|
||||
+25
-1
@@ -10,11 +10,15 @@
|
||||
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
||||
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
||||
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
||||
// lazysecrets://share/<sealed files>
|
||||
//
|
||||
// A repo is its git remote without scheme or .git
|
||||
// (git.kebcloud.no/keb/infra), the file its path in the repo and the key
|
||||
// its path in the file (db.password); slashes inside them are escaped.
|
||||
//
|
||||
// A share link carries files, encrypted (package share), instead of
|
||||
// naming something.
|
||||
//
|
||||
// Vault hosts always contain dots, so "subscriptions" can't be one.
|
||||
//
|
||||
// Every path segment is percent-encoded, so names may hold any character.
|
||||
@@ -49,10 +53,19 @@ type Ref struct {
|
||||
Type provider.ObjectType
|
||||
Name string // object name; empty for a container link
|
||||
Version string
|
||||
// Share is a share link's sealed files, as in the link; the other
|
||||
// fields are empty. Package share reads it.
|
||||
Share string
|
||||
}
|
||||
|
||||
// IsShare reports whether r is a share link.
|
||||
func (r Ref) IsShare() bool { return r.Share != "" }
|
||||
|
||||
// IsSource reports whether r points at a subscription or workspace.
|
||||
func (r Ref) IsSource() bool {
|
||||
if r.IsShare() {
|
||||
return false
|
||||
}
|
||||
if r.Kind != provider.KindAzure {
|
||||
return r.Scope == ""
|
||||
}
|
||||
@@ -60,7 +73,7 @@ func (r Ref) IsSource() bool {
|
||||
}
|
||||
|
||||
// IsContainer reports whether r points at a vault or scope.
|
||||
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() }
|
||||
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() }
|
||||
|
||||
// ContainerName is the vault name (from its host) or the scope.
|
||||
func (r Ref) ContainerName() string {
|
||||
@@ -111,6 +124,9 @@ func (r Ref) IdentifierURL() string {
|
||||
|
||||
// String is the lazysecrets:// link.
|
||||
func (r Ref) String() string {
|
||||
if r.IsShare() {
|
||||
return Scheme + "://share/" + r.Share
|
||||
}
|
||||
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
||||
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
|
||||
}
|
||||
@@ -208,6 +224,8 @@ var (
|
||||
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
||||
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
||||
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
|
||||
// a share link's sealed files: unpadded base64url
|
||||
sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`)
|
||||
)
|
||||
|
||||
// Parse reads any supported reference.
|
||||
@@ -238,6 +256,12 @@ func parseLink(s string) (Ref, error) {
|
||||
if err != nil {
|
||||
return Ref{}, err
|
||||
}
|
||||
if strings.EqualFold(u.Host, "share") {
|
||||
if len(segs) != 1 || !sealed.MatchString(segs[0]) {
|
||||
return Ref{}, errors.New("share link is cut short or garbled: copy all of it")
|
||||
}
|
||||
return Ref{Share: segs[0]}, nil
|
||||
}
|
||||
var r Ref
|
||||
if len(segs) == 0 || segs[0] == "" {
|
||||
return Ref{}, errors.New("link has no vault or workspace")
|
||||
|
||||
Reference in new issue
Block a user