cmd/uplinkng: access creation/restriction and review fixes

Change-Id: I649ae3615363685c28c39d1efb6a65fcad507f46
This commit is contained in:
Jeff Wendling 2021-06-22 18:41:22 -04:00
parent dc69e1b16e
commit e33f8d7170
15 changed files with 307 additions and 93 deletions

View File

@ -0,0 +1,110 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package main
import (
"fmt"
"strconv"
"github.com/zeebo/clingy"
"github.com/zeebo/errs"
"storj.io/storj/cmd/uplinkng/ulext"
"storj.io/uplink"
)
type accessMaker struct {
ex ulext.External
print bool
save bool
name string
force bool
use bool
perms accessPermissions
}
func (am *accessMaker) Setup(params clingy.Parameters, ex ulext.External, forceSave bool) {
am.ex = ex
am.save = forceSave
am.print = !forceSave
if !forceSave {
am.save = params.Flag("save", "Save the access", true,
clingy.Transform(strconv.ParseBool),
).(bool)
}
am.name = params.Flag("name", "Name to save newly created access, if --save is true", "").(string)
am.force = params.Flag("force", "Force overwrite an existing saved access grant", false,
clingy.Short('f'),
clingy.Transform(strconv.ParseBool),
).(bool)
am.use = params.Flag("use", "Set the saved access to be the one used by default", false,
clingy.Transform(strconv.ParseBool),
).(bool)
if !forceSave {
params.Break()
am.perms.Setup(params)
}
}
func (am *accessMaker) Execute(ctx clingy.Context, access *uplink.Access) (err error) {
defaultName, accesses, err := am.ex.GetAccessInfo(false)
if err != nil {
return err
}
if am.save {
// pick a default name for the access if we're saving and there are
// no saved accesses. otherwise, prompt.
if am.name == "" && len(accesses) == 0 {
am.name = "default"
}
if am.name == "" {
am.name, err = am.ex.PromptInput(ctx, "Name:")
if err != nil {
return errs.Wrap(err)
}
}
if _, ok := accesses[am.name]; ok && !am.force {
return errs.New("Access %q already exists. Overwrite by specifying --force or choose a new name with --name", am.name)
}
}
access, err = am.perms.Apply(access)
if err != nil {
return errs.Wrap(err)
}
accessValue, err := access.Serialize()
if err != nil {
return errs.Wrap(err)
}
if am.print {
fmt.Fprintln(ctx, accessValue)
}
if am.save {
accesses[am.name] = accessValue
if am.use || defaultName == "" {
defaultName = am.name
}
if err := am.ex.SaveAccessInfo(defaultName, accesses); err != nil {
return errs.Wrap(err)
}
fmt.Fprintf(ctx, "Access %q saved to %q\n", am.name, am.ex.AccessInfoFile())
}
return nil
}

View File

@ -8,12 +8,16 @@ import (
"time"
"github.com/zeebo/clingy"
"github.com/zeebo/errs"
"storj.io/storj/cmd/uplinkng/ulloc"
"storj.io/uplink"
)
// accessPermissions holds flags and provides a Setup method for commands that
// have to modify permissions on access grants.
type accessPermissions struct {
prefixes []string // prefixes is the set of path prefixes that the grant will be limited to
prefixes []uplink.SharePrefix // prefixes is the set of path prefixes that the grant will be limited to
readonly bool // implies disallowWrites and disallowDeletes
writeonly bool // implies disallowReads and disallowLists
@ -28,10 +32,24 @@ type accessPermissions struct {
}
func (ap *accessPermissions) Setup(params clingy.Parameters) {
ap.prefixes = params.Flag("prefix", "Key prefix access will be restricted to", []string{},
clingy.Repeated).([]string)
transformSharePrefix := func(loc ulloc.Location) (uplink.SharePrefix, error) {
bucket, key, ok := loc.RemoteParts()
if !ok {
return uplink.SharePrefix{}, errs.New("invalid prefix: must be remote: %q", loc)
}
return uplink.SharePrefix{
Bucket: bucket,
Prefix: key,
}, nil
}
ap.readonly = params.Flag("readonly", "Implies --disallow-writes and --disallow-deletes", true,
ap.prefixes = params.Flag("prefix", "Key prefix access will be restricted to", []ulloc.Location{},
clingy.Transform(ulloc.Parse),
clingy.Transform(transformSharePrefix),
clingy.Repeated,
).([]uplink.SharePrefix)
ap.readonly = params.Flag("readonly", "Implies --disallow-writes and --disallow-deletes", false,
clingy.Transform(strconv.ParseBool)).(bool)
ap.writeonly = params.Flag("writeonly", "Implies --disallow-reads and --disallow-lists", false,
clingy.Transform(strconv.ParseBool)).(bool)
@ -45,24 +63,44 @@ func (ap *accessPermissions) Setup(params clingy.Parameters) {
ap.disallowWrites = params.Flag("disallow-writes", "Disallow writes with the access", false,
clingy.Transform(strconv.ParseBool)).(bool)
now := time.Now()
transformHumanDate := clingy.Transform(func(date string) (time.Time, error) {
switch {
case date == "":
return time.Time{}, nil
case date == "now":
return now, nil
case date[0] == '+' || date[0] == '-':
d, err := time.ParseDuration(date)
return now.Add(d), errs.Wrap(err)
default:
t, err := time.Parse(time.RFC3339, date)
return t, errs.Wrap(err)
}
})
ap.notBefore = params.Flag("not-before",
"Disallow access before this time (e.g. '+2h', '2020-01-02T15:04:05Z0700')",
time.Time{}, clingy.Transform(parseRelativeTime), clingy.Type("relative_time")).(time.Time)
"Disallow access before this time (e.g. '+2h', 'now', '2020-01-02T15:04:05Z0700')",
time.Time{}, transformHumanDate, clingy.Type("relative_date")).(time.Time)
ap.notAfter = params.Flag("not-after",
"Disallow access after this time (e.g. '+2h', '2020-01-02T15:04:05Z0700')",
time.Time{}, clingy.Transform(parseRelativeTime), clingy.Type("relative_time")).(time.Time)
"Disallow access after this time (e.g. '+2h', 'now', '2020-01-02T15:04:05Z0700')",
time.Time{}, transformHumanDate, clingy.Type("relative_date")).(time.Time)
}
func parseRelativeTime(v string) (time.Time, error) {
if len(v) == 0 {
return time.Time{}, nil
} else if v[0] == '+' || v[0] == '-' {
d, err := time.ParseDuration(v)
if err != nil {
return time.Time{}, err
}
return time.Now().Add(d), nil
} else {
return time.Parse(time.RFC3339, v)
func (ap *accessPermissions) Apply(access *uplink.Access) (*uplink.Access, error) {
permission := uplink.Permission{
AllowDelete: !ap.disallowDeletes && !ap.readonly,
AllowList: !ap.disallowLists && !ap.writeonly,
AllowDownload: !ap.disallowReads && !ap.writeonly,
AllowUpload: !ap.disallowWrites && !ap.readonly,
NotBefore: ap.notBefore,
NotAfter: ap.notAfter,
}
access, err := access.Share(permission, ap.prefixes...)
if err != nil {
return nil, errs.Wrap(err)
}
return access, nil
}

View File

@ -4,22 +4,18 @@
package main
import (
"strconv"
"github.com/zeebo/clingy"
"github.com/zeebo/errs"
"storj.io/storj/cmd/uplinkng/ulext"
)
type cmdAccessCreate struct {
ex ulext.External
accessPermissions
am accessMaker
token string
passphrase string
name string
save bool
}
func newCmdAccessCreate(ex ulext.External) *cmdAccessCreate {
@ -29,12 +25,31 @@ func newCmdAccessCreate(ex ulext.External) *cmdAccessCreate {
func (c *cmdAccessCreate) Setup(params clingy.Parameters) {
c.token = params.Flag("token", "Setup token from satellite UI (prompted if unspecified)", "").(string)
c.passphrase = params.Flag("passphrase", "Passphrase used for encryption (prompted if unspecified)", "").(string)
c.name = params.Flag("name", "Name to save newly created access, if --save is true", "default").(string)
c.save = params.Flag("save", "Save the access", true, clingy.Transform(strconv.ParseBool)).(bool)
c.accessPermissions.Setup(params)
params.Break()
c.am.Setup(params, c.ex, false)
}
func (c *cmdAccessCreate) Execute(ctx clingy.Context) error {
return nil
func (c *cmdAccessCreate) Execute(ctx clingy.Context) (err error) {
if c.token == "" {
c.token, err = c.ex.PromptInput(ctx, "Setup token:")
if err != nil {
return errs.Wrap(err)
}
}
if c.passphrase == "" {
// TODO: secret prompt
c.passphrase, err = c.ex.PromptInput(ctx, "Passphrase:")
if err != nil {
return errs.Wrap(err)
}
}
access, err := c.ex.RequestAccess(ctx, c.token, c.passphrase)
if err != nil {
return errs.Wrap(err)
}
return c.am.Execute(ctx, access)
}

View File

@ -0,0 +1,37 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package main
import (
"github.com/zeebo/clingy"
"storj.io/storj/cmd/uplinkng/ulext"
)
type cmdAccessRestrict struct {
ex ulext.External
am accessMaker
access string
}
func newCmdAccessRestrict(ex ulext.External) *cmdAccessRestrict {
return &cmdAccessRestrict{ex: ex}
}
func (c *cmdAccessRestrict) Setup(params clingy.Parameters) {
c.access = params.Flag("access", "Which access to restrict", "").(string)
params.Break()
c.am.Setup(params, c.ex, false)
}
func (c *cmdAccessRestrict) Execute(ctx clingy.Context) error {
access, err := c.ex.OpenAccess(c.access)
if err != nil {
return err
}
return c.am.Execute(ctx, access)
}

View File

@ -4,8 +4,6 @@
package main
import (
"strconv"
"github.com/zeebo/clingy"
"github.com/zeebo/errs"
@ -15,11 +13,9 @@ import (
type cmdAccessSave struct {
ex ulext.External
am accessMaker
access string
name string
force bool
use bool
}
func newCmdAccessSave(ex ulext.External) *cmdAccessSave {
@ -27,24 +23,13 @@ func newCmdAccessSave(ex ulext.External) *cmdAccessSave {
}
func (c *cmdAccessSave) Setup(params clingy.Parameters) {
c.access = params.Flag("access", "Access to save (prompted if unspecified)", "").(string)
c.name = params.Flag("name", "Name to save the access grant under", "default").(string)
c.access = params.Flag("access", "Access value to save (prompted if unspecified)", "").(string)
c.force = params.Flag("force", "Force overwrite an existing saved access grant", false,
clingy.Short('f'),
clingy.Transform(strconv.ParseBool),
).(bool)
c.use = params.Flag("use", "Set the saved access to be the one used by default", false,
clingy.Transform(strconv.ParseBool),
).(bool)
params.Break()
c.am.Setup(params, c.ex, true)
}
func (c *cmdAccessSave) Execute(ctx clingy.Context) error {
defaultName, accesses, err := c.ex.GetAccessInfo(false)
if err != nil {
return err
}
func (c *cmdAccessSave) Execute(ctx clingy.Context) (err error) {
if c.access == "" {
c.access, err = c.ex.PromptInput(ctx, "Access:")
if err != nil {
@ -52,17 +37,10 @@ func (c *cmdAccessSave) Execute(ctx clingy.Context) error {
}
}
if _, err := uplink.ParseAccess(c.access); err != nil {
access, err := uplink.ParseAccess(c.access)
if err != nil {
return err
}
if _, ok := accesses[c.name]; ok && !c.force {
return errs.New("Access %q already exists. Overwrite by specifying --force or choose a new name with --name", c.name)
}
accesses[c.name] = c.access
if c.use || defaultName == "" {
defaultName = c.name
}
return c.ex.SaveAccessInfo(defaultName, accesses)
return c.am.Execute(ctx, access)
}

View File

@ -66,8 +66,8 @@ func (ex *external) Setup(f clingy.Flags) {
ex.dirs.loaded = true
}
func (ex *external) accessFile() string { return filepath.Join(ex.dirs.current, "access.json") }
func (ex *external) configFile() string { return filepath.Join(ex.dirs.current, "config.ini") }
func (ex *external) AccessInfoFile() string { return filepath.Join(ex.dirs.current, "access.json") }
func (ex *external) ConfigFile() string { return filepath.Join(ex.dirs.current, "config.ini") }
func (ex *external) legacyConfigFile() string { return filepath.Join(ex.dirs.legacy, "config.yaml") }
// Dynamic is called by clingy to look up values for global flags not specified on the command

View File

@ -4,10 +4,14 @@
package main
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/zeebo/errs"
"storj.io/uplink"
)
func (ex *external) loadAccesses() error {
@ -15,7 +19,7 @@ func (ex *external) loadAccesses() error {
return nil
}
fh, err := os.Open(ex.accessFile())
fh, err := os.Open(ex.AccessInfoFile())
if os.IsNotExist(err) {
return nil
} else if err != nil {
@ -39,6 +43,29 @@ func (ex *external) loadAccesses() error {
return nil
}
func (ex *external) OpenAccess(accessName string) (access *uplink.Access, err error) {
accessDefault, accesses, err := ex.GetAccessInfo(true)
if err != nil {
return nil, err
}
if accessName != "" {
accessDefault = accessName
}
if data, ok := accesses[accessDefault]; ok {
access, err = uplink.ParseAccess(data)
} else {
access, err = uplink.ParseAccess(accessDefault)
// TODO: if this errors then it's probably a name so don't report an error
// that says "it failed to parse"
}
if err != nil {
return nil, err
}
return access, nil
}
func (ex *external) GetAccessInfo(required bool) (string, map[string]string, error) {
if !ex.access.loaded {
if err := ex.loadAccesses(); err != nil {
@ -62,7 +89,7 @@ func (ex *external) GetAccessInfo(required bool) (string, map[string]string, err
func (ex *external) SaveAccessInfo(defaultName string, accesses map[string]string) error {
// TODO(jeff): write it atomically
accessFh, err := os.OpenFile(ex.accessFile(), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
accessFh, err := os.OpenFile(ex.AccessInfoFile(), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return errs.Wrap(err)
}
@ -95,3 +122,17 @@ func (ex *external) SaveAccessInfo(defaultName string, accesses map[string]strin
return nil
}
func (ex *external) RequestAccess(ctx context.Context, token, passphrase string) (*uplink.Access, error) {
idx := strings.IndexByte(token, '/')
if idx == -1 {
return nil, errs.New("invalid setup token. should be 'satelliteAddress/apiKey'")
}
satelliteAddr, apiKey := token[:idx], token[idx+1:]
access, err := uplink.RequestAccessWithPassphrase(ctx, satelliteAddr, apiKey, passphrase)
if err != nil {
return nil, errs.Wrap(err)
}
return access, nil
}

View File

@ -18,7 +18,7 @@ func (ex *external) loadConfig() error {
}
ex.config.values = make(map[string][]string)
fh, err := os.Open(ex.configFile())
fh, err := os.Open(ex.ConfigFile())
if os.IsNotExist(err) {
return nil
} else if err != nil {
@ -46,7 +46,7 @@ func (ex *external) loadConfig() error {
func (ex *external) saveConfig(entries []ini.Entry) error {
// TODO(jeff): write it atomically
newFh, err := os.Create(ex.configFile())
newFh, err := os.Create(ex.ConfigFile())
if err != nil {
return errs.Wrap(err)
}

View File

@ -26,7 +26,7 @@ func (ex *external) migrate() (err error) {
defer func() { ex.migration.err = err }()
// if the config file exists, there is no need to migrate
if _, err := os.Stat(ex.configFile()); err == nil {
if _, err := os.Stat(ex.ConfigFile()); err == nil {
return nil
}

View File

@ -23,22 +23,7 @@ func (ex *external) OpenFilesystem(ctx context.Context, accessName string, optio
func (ex *external) OpenProject(ctx context.Context, accessName string, options ...ulext.Option) (*uplink.Project, error) {
opts := ulext.LoadOptions(options...)
accessDefault, accesses, err := ex.GetAccessInfo(true)
if err != nil {
return nil, err
}
if accessName != "" {
accessDefault = accessName
}
var access *uplink.Access
if data, ok := accesses[accessDefault]; ok {
access, err = uplink.ParseAccess(data)
} else {
access, err = uplink.ParseAccess(accessDefault)
// TODO: if this errors then it's probably a name so don't report an error
// that says "it failed to parse"
}
access, err := ex.OpenAccess(accessName)
if err != nil {
return nil, err
}

View File

@ -40,6 +40,7 @@ func commands(cmds clingy.Commands, ex ulext.External) {
cmds.New("save", "Save an existing access", newCmdAccessSave(ex))
cmds.New("create", "Create an access from a setup token", newCmdAccessCreate(ex))
cmds.New("delete", "Delete an access from local store", newCmdAccessDelete(ex))
cmds.New("restrict", "Restrict an access", newCmdAccessRestrict(ex))
cmds.New("list", "List saved accesses", newCmdAccessList(ex))
cmds.New("use", "Set default access to use", newCmdAccessUse(ex))
cmds.New("revoke", "Revoke an access", newCmdAccessRevoke(ex))

View File

@ -19,8 +19,11 @@ type External interface {
OpenFilesystem(ctx context.Context, accessName string, options ...Option) (ulfs.Filesystem, error)
OpenProject(ctx context.Context, accessName string, options ...Option) (*uplink.Project, error)
AccessInfoFile() string
OpenAccess(accessName string) (access *uplink.Access, err error)
GetAccessInfo(required bool) (string, map[string]string, error)
SaveAccessInfo(defaultName string, accesses map[string]string) error
RequestAccess(ctx context.Context, token, passphrase string) (*uplink.Access, error)
PromptInput(ctx clingy.Context, prompt string) (input string, err error)
}

View File

@ -32,6 +32,9 @@ func (l *Local) abs(path string) (string, error) {
!strings.HasSuffix(abs, string(filepath.Separator)) {
abs += string(filepath.Separator)
}
if filepath.Separator != '/' {
abs = strings.ReplaceAll(abs, string(filepath.Separator), "/")
}
return abs, nil
}
@ -108,9 +111,13 @@ func (l *Local) ListObjects(ctx context.Context, path string, recursive bool) (O
if recursive {
err = filepath.Walk(prefix, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
rel, err := filepath.Rel(prefix, path)
if err != nil {
return err
}
files = append(files, &namedFileInfo{
FileInfo: info,
name: path[len(prefix):],
name: rel,
})
}
return nil
@ -175,13 +182,6 @@ func (fi *fileinfoObjectIterator) Item() ObjectInfo {
if isDir {
name += string(filepath.Separator)
}
// TODO(jeff): is this the right thing to do on windows? is there more to do?
// convert the paths to be forward slash based because keys are supposed to always be remote
if filepath.Separator != '/' {
name = strings.ReplaceAll(name, string(filepath.Separator), "/")
}
return ObjectInfo{
Loc: ulloc.NewLocal(name),
IsPrefix: isDir,

View File

@ -15,6 +15,8 @@ import (
)
type external struct {
ulext.External
fs ulfs.Filesystem
project *uplink.Project
}
@ -34,6 +36,10 @@ func (ex *external) OpenProject(ctx context.Context, access string, options ...u
return ex.project, nil
}
func (ex *external) OpenAccess(accessName string) (access *uplink.Access, err error) {
return nil, errs.New("not implemented")
}
func (ex *external) GetAccessInfo(required bool) (string, map[string]string, error) {
return "", nil, errs.New("not implemented")
}

View File

@ -26,8 +26,8 @@ type Result struct {
func (r Result) RequireSuccess(t *testing.T) {
if !r.Ok {
errs := parseErrors(r.Stdout)
require.True(t, r.Ok, "test did not run successfully. errors:\n%s",
strings.Join(errs, "\n"))
require.FailNow(t, "test did not run successfully",
"%s", strings.Join(errs, "\n"))
}
require.NoError(t, r.Err)
}