diff --git a/cmd/s3-authmate/modules/issue-secret.go b/cmd/s3-authmate/modules/issue-secret.go new file mode 100644 index 0000000..f8ccf8d --- /dev/null +++ b/cmd/s3-authmate/modules/issue-secret.go @@ -0,0 +1,176 @@ +package modules + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/authmate" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/wallet" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var issueSecretCmd = &cobra.Command{ + Use: "issue-secret", + Short: "Issue a secret in FrostFS network", + Long: "Creates new s3 credentials to use with frostfs-s3-gw", + Example: `frostfs-s3-authmate issue-secret --wallet wallet.json --peer s01.frostfs.devenv:8080 --gate-public-key 031a6c6fbbdf02ca351745fa86b9ba5a9452d785ac4f7fc2b7548ca2a46c4fcf4a`, + RunE: runIssueSecretCmd, +} + +const ( + walletFlag = "wallet" + addressFlag = "address" + peerFlag = "peer" + bearerRulesFlag = "bearer-rules" + disableImpersonateFlag = "disable-impersonate" + gatePublicKeyFlag = "gate-public-key" + containerIDFlag = "container-id" + containerFriendlyNameFlag = "container-friendly-name" + containerPlacementPolicyFlag = "container-placement-policy" + sessionTokensFlag = "session-tokens" + lifetimeFlag = "lifetime" + containerPolicyFlag = "container-policy" + awsCLICredentialFlag = "aws-cli-credentials" +) + +const ( + walletPassphraseCfg = "wallet.passphrase" +) + +const ( + defaultAccessBoxLifetime = 30 * 24 * time.Hour + + defaultPoolDialTimeout = 5 * time.Second + defaultPoolHealthcheckTimeout = 5 * time.Second + defaultPoolRebalanceInterval = 30 * time.Second + defaultPoolStreamTimeout = 10 * time.Second +) + +const ( + poolDialTimeoutFlag = "pool-dial-timeout" + poolHealthcheckTimeoutFlag = "pool-healthcheck-timeout" + poolRebalanceIntervalFlag = "pool-rebalance-interval" + poolStreamTimeoutFlag = "pool-stream-timeout" +) + +func initIssueSecretCmd() { + issueSecretCmd.Flags().String(walletFlag, "", "Path to the wallet that will be owner of the credentials") + issueSecretCmd.Flags().String(addressFlag, "", "Address of the wallet account") + issueSecretCmd.Flags().String(peerFlag, "", "Address of a frostfs peer to connect to") + issueSecretCmd.Flags().String(bearerRulesFlag, "", "Rules for bearer token (filepath or a plain json string are allowed, can be used only with --disable-impersonate)") + issueSecretCmd.Flags().Bool(disableImpersonateFlag, false, "Mark token as not impersonate to don't consider token signer as request owner (must be provided to use --bearer-rules flag)") + issueSecretCmd.Flags().StringSlice(gatePublicKeyFlag, nil, "Public 256r1 key of a gate (use flags repeatedly for multiple gates or separate them by comma)") + issueSecretCmd.Flags().String(containerIDFlag, "", "Auth container id to put the secret into (if not provided new container will be created)") + issueSecretCmd.Flags().String(containerFriendlyNameFlag, "", "Friendly name of auth container to put the secret into (flag value will be used only if --container-id is missed)") + issueSecretCmd.Flags().String(containerPlacementPolicyFlag, "REP 2 IN X CBF 3 SELECT 2 FROM * AS X", "Placement policy of auth container to put the secret into (flag value will be used only if --container-id is missed)") + issueSecretCmd.Flags().String(sessionTokensFlag, "", "create session tokens with rules, if the rules are set as 'none', no session tokens will be created") + issueSecretCmd.Flags().Duration(lifetimeFlag, defaultAccessBoxLifetime, "Lifetime of tokens. For example 50h30m (note: max time unit is an hour so to set a day you should use 24h).\nIt will be ceil rounded to the nearest amount of epoch.") + issueSecretCmd.Flags().String(containerPolicyFlag, "", "Mapping AWS storage class to FrostFS storage policy as plain json string or path to json file") + issueSecretCmd.Flags().String(awsCLICredentialFlag, "", "Path to the aws cli credential file") + issueSecretCmd.Flags().Duration(poolDialTimeoutFlag, defaultPoolDialTimeout, "Timeout for connection to the node in pool to be established") + issueSecretCmd.Flags().Duration(poolHealthcheckTimeoutFlag, defaultPoolHealthcheckTimeout, "Timeout for request to node to decide if it is alive") + issueSecretCmd.Flags().Duration(poolRebalanceIntervalFlag, defaultPoolRebalanceInterval, "Interval for updating nodes health status") + issueSecretCmd.Flags().Duration(poolStreamTimeoutFlag, defaultPoolStreamTimeout, "Timeout for individual operation in streaming RPC") + + _ = issueSecretCmd.MarkFlagRequired(walletFlag) + _ = issueSecretCmd.MarkFlagRequired(peerFlag) + _ = issueSecretCmd.MarkFlagRequired(gatePublicKeyFlag) +} + +func runIssueSecretCmd(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), viper.GetDuration(timeoutFlag)) + defer cancel() + + log := getLogger() + + password := wallet.GetPassword(viper.GetViper(), walletPassphraseCfg) + key, err := wallet.GetKeyFromPath(viper.GetString(walletFlag), viper.GetString(addressFlag), password) + if err != nil { + return fmt.Errorf("failed to load frostfs private key: %s", err) + } + + var cnrID cid.ID + containerID := viper.GetString(containerIDFlag) + if len(containerID) > 0 { + if err = cnrID.DecodeString(containerID); err != nil { + return fmt.Errorf("failed to parse auth container id: %s", err) + } + } + + var gatesPublicKeys []*keys.PublicKey + for _, keyStr := range viper.GetStringSlice(gatePublicKeyFlag) { + gpk, err := keys.NewPublicKeyFromString(keyStr) + if err != nil { + return fmt.Errorf("failed to load gate's public key: %s", err) + } + gatesPublicKeys = append(gatesPublicKeys, gpk) + } + + lifetime := viper.GetDuration(lifetimeFlag) + if lifetime <= 0 { + return fmt.Errorf("lifetime must be greater 0, current value: %d", lifetime) + } + + policies, err := parsePolicies(viper.GetString(containerPolicyFlag)) + if err != nil { + return fmt.Errorf("couldn't parse container policy: %s", err.Error()) + } + + disableImpersonate := viper.GetBool(disableImpersonateFlag) + eaclRules := viper.GetString(bearerRulesFlag) + if !disableImpersonate && eaclRules != "" { + return errors.New("--bearer-rules flag can be used only with --disable-impersonate") + } + + bearerRules, err := getJSONRules(eaclRules) + if err != nil { + return fmt.Errorf("couldn't parse 'bearer-rules' flag: %s", err.Error()) + } + + sessionRules, skipSessionRules, err := getSessionRules(viper.GetString(sessionTokensFlag)) + if err != nil { + return fmt.Errorf("couldn't parse 'session-tokens' flag: %s", err.Error()) + } + + poolCfg := PoolConfig{ + Key: &key.PrivateKey, + Address: viper.GetString(peerFlag), + DialTimeout: viper.GetDuration(poolDialTimeoutFlag), + HealthcheckTimeout: viper.GetDuration(poolHealthcheckTimeoutFlag), + StreamTimeout: viper.GetDuration(poolStreamTimeoutFlag), + RebalanceInterval: viper.GetDuration(poolRebalanceIntervalFlag), + } + + frostFS, err := createFrostFS(ctx, log, poolCfg) + if err != nil { + return fmt.Errorf("failed to create FrostFS component: %s", err) + } + + issueSecretOptions := &authmate.IssueSecretOptions{ + Container: authmate.ContainerOptions{ + ID: cnrID, + FriendlyName: viper.GetString(containerFriendlyNameFlag), + PlacementPolicy: viper.GetString(containerPlacementPolicyFlag), + }, + FrostFSKey: key, + GatesPublicKeys: gatesPublicKeys, + EACLRules: bearerRules, + Impersonate: !disableImpersonate, + SessionTokenRules: sessionRules, + SkipSessionRules: skipSessionRules, + ContainerPolicies: policies, + Lifetime: lifetime, + AwsCliCredentialsFile: viper.GetString(awsCLICredentialFlag), + } + + if err = authmate.New(log, frostFS).IssueSecret(ctx, os.Stdout, issueSecretOptions); err != nil { + return fmt.Errorf("failed to issue secret: %s", err) + } + return nil +} diff --git a/cmd/s3-authmate/modules/root.go b/cmd/s3-authmate/modules/root.go index 72d82e7..6b664ef 100644 --- a/cmd/s3-authmate/modules/root.go +++ b/cmd/s3-authmate/modules/root.go @@ -53,4 +53,7 @@ func init() { {{printf "Version: %s" .Version }} GoVersion: {{ runtimeVersion }} `) + + rootCmd.AddCommand(issueSecretCmd) + initIssueSecretCmd() } diff --git a/cmd/s3-authmate/modules/utils.go b/cmd/s3-authmate/modules/utils.go new file mode 100644 index 0000000..24304e3 --- /dev/null +++ b/cmd/s3-authmate/modules/utils.go @@ -0,0 +1,142 @@ +package modules + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "fmt" + "os" + "time" + + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/authmate" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/pool" + "github.com/spf13/viper" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type PoolConfig struct { + Key *ecdsa.PrivateKey + Address string + DialTimeout time.Duration + HealthcheckTimeout time.Duration + StreamTimeout time.Duration + RebalanceInterval time.Duration +} + +func createFrostFS(ctx context.Context, log *zap.Logger, cfg PoolConfig) (authmate.FrostFS, error) { + log.Debug("prepare connection pool") + + var prm pool.InitParameters + prm.SetKey(cfg.Key) + prm.SetNodeDialTimeout(cfg.DialTimeout) + prm.SetHealthcheckTimeout(cfg.HealthcheckTimeout) + prm.SetNodeStreamTimeout(cfg.StreamTimeout) + prm.SetClientRebalanceInterval(cfg.RebalanceInterval) + prm.SetLogger(log) + prm.AddNode(pool.NewNodeParam(1, cfg.Address, 1)) + + p, err := pool.NewPool(prm) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + + if err = p.Dial(ctx); err != nil { + return nil, fmt.Errorf("dial pool: %w", err) + } + + return frostfs.NewAuthmateFrostFS(p), nil +} + +func parsePolicies(val string) (authmate.ContainerPolicies, error) { + if val == "" { + return nil, nil + } + + var ( + data = []byte(val) + err error + ) + + if !json.Valid(data) { + if data, err = os.ReadFile(val); err != nil { + return nil, fmt.Errorf("coudln't read json file or provided json is invalid") + } + } + + var policies authmate.ContainerPolicies + if err = json.Unmarshal(data, &policies); err != nil { + return nil, fmt.Errorf("unmarshal policies: %w", err) + } + if _, ok := policies[api.DefaultLocationConstraint]; ok { + return nil, fmt.Errorf("config overrides %s location constraint", api.DefaultLocationConstraint) + } + + return policies, nil +} + +func getJSONRules(val string) ([]byte, error) { + if val == "" { + return nil, nil + } + data := []byte(val) + if json.Valid(data) { + return data, nil + } + + if data, err := os.ReadFile(val); err == nil { + if json.Valid(data) { + return data, nil + } + } + + return nil, fmt.Errorf("coudln't read json file or provided json is invalid") +} + +// getSessionRules reads json session rules. +// It returns true if rules must be skipped. +func getSessionRules(r string) ([]byte, bool, error) { + if r == "none" { + return nil, true, nil + } + + data, err := getJSONRules(r) + return data, false, err +} + +// getLogger returns new logger depending on appropriate values in viper.Viper +// if logger cannot be built it panics. +func getLogger() *zap.Logger { + if !viper.GetBool(withLogFlag) { + return zap.NewNop() + } + + var zapConfig = zap.Config{ + Development: true, + Encoding: "console", + Level: zap.NewAtomicLevelAt(zapcore.FatalLevel), + OutputPaths: []string{"stdout"}, + EncoderConfig: zapcore.EncoderConfig{ + MessageKey: "message", + LevelKey: "level", + EncodeLevel: zapcore.CapitalLevelEncoder, + TimeKey: "time", + EncodeTime: zapcore.ISO8601TimeEncoder, + CallerKey: "caller", + EncodeCaller: zapcore.ShortCallerEncoder, + }, + } + + if viper.GetBool(debugFlag) { + zapConfig.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) + } + + log, err := zapConfig.Build() + if err != nil { + panic(fmt.Errorf("create logger: %w", err)) + } + + return log +}