frostfs-s3-gw/authmate/authmate.go
Denis Kirillov eae49908da
All checks were successful
/ DCO (pull_request) Successful in 1m36s
/ Builds (1.20) (pull_request) Successful in 2m2s
/ Builds (1.21) (pull_request) Successful in 1m28s
/ Vulncheck (pull_request) Successful in 1m53s
/ Lint (pull_request) Successful in 3m19s
/ Tests (1.20) (pull_request) Successful in 2m24s
/ Tests (1.21) (pull_request) Successful in 2m11s
[#292] authmate: Support custom attributes
Signed-off-by: Denis Kirillov <d.kirillov@yadro.com>
2024-01-22 10:02:43 +03:00

615 lines
19 KiB
Go

package authmate
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"time"
"git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/acl"
sessionv2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/session"
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/cache"
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox"
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/tokens"
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
frostfsecdsa "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/crypto/ecdsa"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/eacl"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/netmap"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/session"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
"github.com/google/uuid"
"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
"go.uber.org/zap"
)
// PrmContainerCreate groups parameters of containers created by authmate.
type PrmContainerCreate struct {
// FrostFS identifier of the container creator.
Owner user.ID
// Container placement policy.
Policy netmap.PlacementPolicy
// Friendly name for the container (optional).
FriendlyName string
}
// NetworkState represents FrostFS network state which is needed for authmate processing.
type NetworkState struct {
// Current FrostFS time.
Epoch uint64
// Duration of the Morph chain block in ms.
BlockDuration int64
// Duration of the FrostFS epoch in Morph chain blocks.
EpochDuration uint64
}
// FrostFS represents virtual connection to FrostFS network.
type FrostFS interface {
// FrostFS interface required by credential tool.
tokens.FrostFS
// ContainerExists checks container presence in FrostFS by identifier.
// Returns nil if container exists.
ContainerExists(context.Context, cid.ID) error
// CreateContainer creates and saves parameterized container in FrostFS.
// It sets 'Timestamp' attribute to the current time.
// It returns the ID of the saved container.
//
// The container must be private with GET access for OTHERS group.
// Creation time should also be stamped.
//
// It returns exactly one non-nil value. It returns any error encountered which
// prevented the container from being created.
CreateContainer(context.Context, PrmContainerCreate) (cid.ID, error)
// TimeToEpoch computes the current epoch and the epoch that corresponds to the provided time.
// Note:
// * time must be in the future
// * time will be ceil rounded to match epoch
//
// It returns any error encountered which prevented computing epochs.
TimeToEpoch(context.Context, time.Time) (uint64, uint64, error)
}
// FrostFSID represents interface to interact with frostfsid contract.
type FrostFSID interface {
RegisterPublicKey(ns string, key *keys.PublicKey) error
}
// Agent contains client communicating with FrostFS and logger.
type Agent struct {
frostFS FrostFS
log *zap.Logger
}
// New creates an object of type Agent that consists of Client and logger.
func New(log *zap.Logger, frostFS FrostFS) *Agent {
return &Agent{log: log, frostFS: frostFS}
}
type (
// ContainerPolicies contains mapping of aws LocationConstraint to frostfs PlacementPolicy.
ContainerPolicies map[string]string
// IssueSecretOptions contains options for passing to Agent.IssueSecret method.
IssueSecretOptions struct {
Container ContainerOptions
FrostFSKey *keys.PrivateKey
GatesPublicKeys []*keys.PublicKey
EACLRules []byte
Impersonate bool
SessionTokenRules []byte
SkipSessionRules bool
Lifetime time.Duration
AwsCliCredentialsFile string
ContainerPolicies ContainerPolicies
CustomAttributes []object.Attribute
}
// UpdateSecretOptions contains options for passing to Agent.UpdateSecret method.
UpdateSecretOptions struct {
FrostFSKey *keys.PrivateKey
GatesPublicKeys []*keys.PublicKey
Address oid.Address
GatePrivateKey *keys.PrivateKey
CustomAttributes []object.Attribute
}
tokenUpdateOptions struct {
frostFSKey *keys.PrivateKey
gatesPublicKeys []*keys.PublicKey
lifetime lifetimeOptions
box *accessbox.Box
}
// ContainerOptions groups parameters of auth container to put the secret into.
ContainerOptions struct {
ID cid.ID
FriendlyName string
PlacementPolicy string
}
// UpdateOptions groups parameters to update existing the secret into.
UpdateOptions struct {
Address oid.Address
SecretAccessKey []byte
}
// ObtainSecretOptions contains options for passing to Agent.ObtainSecret method.
ObtainSecretOptions struct {
SecretAddress string
GatePrivateKey *keys.PrivateKey
}
)
// lifetimeOptions holds FrostFS epochs, iat -- epoch which the token was issued at, exp -- epoch when the token expires.
type lifetimeOptions struct {
Iat uint64
Exp uint64
}
type (
issuingResult struct {
InitialAccessKeyID string `json:"initial_access_key_id"`
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
OwnerPrivateKey string `json:"owner_private_key"`
WalletPublicKey string `json:"wallet_public_key"`
ContainerID string `json:"container_id"`
}
obtainingResult struct {
BearerToken *bearer.Token `json:"bearer_token"`
SecretAccessKey string `json:"secret_access_key"`
}
)
func (a *Agent) checkContainer(ctx context.Context, opts ContainerOptions, idOwner user.ID) (cid.ID, error) {
if !opts.ID.Equals(cid.ID{}) {
a.log.Info(logs.CheckContainer, zap.Stringer("cid", opts.ID))
return opts.ID, a.frostFS.ContainerExists(ctx, opts.ID)
}
a.log.Info(logs.CreateContainer,
zap.String("friendly_name", opts.FriendlyName),
zap.String("placement_policy", opts.PlacementPolicy))
var prm PrmContainerCreate
err := prm.Policy.DecodeString(opts.PlacementPolicy)
if err != nil {
return cid.ID{}, fmt.Errorf("failed to build placement policy: %w", err)
}
prm.Owner = idOwner
prm.FriendlyName = opts.FriendlyName
cnrID, err := a.frostFS.CreateContainer(ctx, prm)
if err != nil {
return cid.ID{}, fmt.Errorf("create container in FrostFS: %w", err)
}
return cnrID, nil
}
func checkPolicy(policyString string) (*netmap.PlacementPolicy, error) {
var result netmap.PlacementPolicy
err := result.DecodeString(policyString)
if err == nil {
return &result, nil
}
if err = result.UnmarshalJSON([]byte(policyString)); err == nil {
return &result, nil
}
return nil, errors.New("can't parse placement policy")
}
func preparePolicy(policy ContainerPolicies) ([]*accessbox.AccessBox_ContainerPolicy, error) {
if policy == nil {
return nil, nil
}
var result []*accessbox.AccessBox_ContainerPolicy
for locationConstraint, placementPolicy := range policy {
parsedPolicy, err := checkPolicy(placementPolicy)
if err != nil {
return nil, fmt.Errorf("check placement policy: %w", err)
}
result = append(result, &accessbox.AccessBox_ContainerPolicy{
LocationConstraint: locationConstraint,
Policy: parsedPolicy.Marshal(),
})
}
return result, nil
}
// IssueSecret creates an auth token, puts it in the FrostFS network and writes to io.Writer a new secret access key.
func (a *Agent) IssueSecret(ctx context.Context, w io.Writer, options *IssueSecretOptions) error {
var (
err error
box *accessbox.AccessBox
lifetime lifetimeOptions
)
policies, err := preparePolicy(options.ContainerPolicies)
if err != nil {
return fmt.Errorf("prepare policies: %w", err)
}
lifetime.Iat, lifetime.Exp, err = a.frostFS.TimeToEpoch(ctx, time.Now().Add(options.Lifetime))
if err != nil {
return fmt.Errorf("fetch time to epoch: %w", err)
}
gatesData, err := createTokens(options, lifetime)
if err != nil {
return fmt.Errorf("create tokens: %w", err)
}
box, secrets, err := accessbox.PackTokens(gatesData, nil)
if err != nil {
return fmt.Errorf("pack tokens: %w", err)
}
box.ContainerPolicy = policies
var idOwner user.ID
user.IDFromKey(&idOwner, options.FrostFSKey.PrivateKey.PublicKey)
id, err := a.checkContainer(ctx, options.Container, idOwner)
if err != nil {
return fmt.Errorf("check container: %w", err)
}
a.log.Info(logs.StoreBearerTokenIntoFrostFS,
zap.Stringer("owner_tkn", idOwner))
creds := tokens.New(a.frostFS, secrets.EphemeralKey, cache.DefaultAccessBoxConfig(a.log))
prm := tokens.CredentialsParam{
OwnerID: idOwner,
AccessBox: box,
Expiration: lifetime.Exp,
Keys: options.GatesPublicKeys,
CustomAttributes: options.CustomAttributes,
}
addr, err := creds.Put(ctx, id, prm)
if err != nil {
return fmt.Errorf("failed to put creds: %w", err)
}
accessKeyID := accessKeyIDFromAddr(addr)
ir := &issuingResult{
InitialAccessKeyID: accessKeyID,
AccessKeyID: accessKeyID,
SecretAccessKey: secrets.SecretKey,
OwnerPrivateKey: hex.EncodeToString(secrets.EphemeralKey.Bytes()),
WalletPublicKey: hex.EncodeToString(options.FrostFSKey.PublicKey().Bytes()),
ContainerID: id.EncodeToString(),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err = enc.Encode(ir); err != nil {
return err
}
if options.AwsCliCredentialsFile != "" {
profileName := "authmate_cred_" + addr.Object().EncodeToString()
if _, err = os.Stat(options.AwsCliCredentialsFile); os.IsNotExist(err) {
profileName = "default"
}
file, err := os.OpenFile(options.AwsCliCredentialsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return fmt.Errorf("couldn't open aws cli credentials file: %w", err)
}
defer file.Close()
if _, err = file.WriteString(fmt.Sprintf("\n[%s]\naws_access_key_id = %s\naws_secret_access_key = %s\n",
profileName, accessKeyID, secrets.SecretKey)); err != nil {
return fmt.Errorf("fails to write to file: %w", err)
}
}
return nil
}
// UpdateSecret updates an auth token (change list of gates that can use credential), puts new cred version to the FrostFS network and writes to io.Writer a result.
func (a *Agent) UpdateSecret(ctx context.Context, w io.Writer, options *UpdateSecretOptions) error {
creds := tokens.New(a.frostFS, options.GatePrivateKey, cache.DefaultAccessBoxConfig(a.log))
box, err := creds.GetBox(ctx, options.Address)
if err != nil {
return fmt.Errorf("get accessbox: %w", err)
}
secret, err := hex.DecodeString(box.Gate.SecretKey)
if err != nil {
return fmt.Errorf("failed to decode secret key access box: %w", err)
}
lifetime := getLifetimeFromGateData(box.Gate)
tokenOptions := tokenUpdateOptions{
frostFSKey: options.FrostFSKey,
gatesPublicKeys: options.GatesPublicKeys,
lifetime: lifetime,
box: box,
}
gatesData, err := formTokensToUpdate(tokenOptions)
if err != nil {
return fmt.Errorf("create tokens: %w", err)
}
updatedBox, secrets, err := accessbox.PackTokens(gatesData, secret)
if err != nil {
return fmt.Errorf("pack tokens: %w", err)
}
var idOwner user.ID
user.IDFromKey(&idOwner, options.FrostFSKey.PrivateKey.PublicKey)
a.log.Info(logs.UpdateAccessCredObjectIntoFrostFS,
zap.Stringer("owner_tkn", idOwner))
prm := tokens.CredentialsParam{
OwnerID: idOwner,
AccessBox: updatedBox,
Expiration: lifetime.Exp,
Keys: options.GatesPublicKeys,
CustomAttributes: options.CustomAttributes,
}
oldAddr := options.Address
addr, err := creds.Update(ctx, oldAddr, prm)
if err != nil {
return fmt.Errorf("failed to update creds: %w", err)
}
ir := &issuingResult{
AccessKeyID: accessKeyIDFromAddr(addr),
InitialAccessKeyID: accessKeyIDFromAddr(oldAddr),
SecretAccessKey: secrets.SecretKey,
OwnerPrivateKey: hex.EncodeToString(secrets.EphemeralKey.Bytes()),
WalletPublicKey: hex.EncodeToString(options.FrostFSKey.PublicKey().Bytes()),
ContainerID: addr.Container().EncodeToString(),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(ir)
}
func getLifetimeFromGateData(gateData *accessbox.GateData) lifetimeOptions {
var btokenv2 acl.BearerToken
gateData.BearerToken.WriteToV2(&btokenv2)
return lifetimeOptions{
Iat: btokenv2.GetBody().GetLifetime().GetIat(),
Exp: btokenv2.GetBody().GetLifetime().GetExp(),
}
}
// ObtainSecret receives an existing secret access key from FrostFS and
// writes to io.Writer the secret access key.
func (a *Agent) ObtainSecret(ctx context.Context, w io.Writer, options *ObtainSecretOptions) error {
bearerCreds := tokens.New(a.frostFS, options.GatePrivateKey, cache.DefaultAccessBoxConfig(a.log))
var addr oid.Address
if err := addr.DecodeString(options.SecretAddress); err != nil {
return fmt.Errorf("failed to parse secret address: %w", err)
}
box, err := bearerCreds.GetBox(ctx, addr)
if err != nil {
return fmt.Errorf("failed to get tokens: %w", err)
}
or := &obtainingResult{
BearerToken: box.Gate.BearerToken,
SecretAccessKey: box.Gate.SecretKey,
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(or)
}
func buildEACLTable(eaclTable []byte) (*eacl.Table, error) {
table := eacl.NewTable()
if len(eaclTable) != 0 {
return table, table.UnmarshalJSON(eaclTable)
}
record := eacl.NewRecord()
record.SetOperation(eacl.OperationGet)
record.SetAction(eacl.ActionAllow)
eacl.AddFormedTarget(record, eacl.RoleOthers)
table.AddRecord(record)
for _, rec := range restrictedRecords() {
table.AddRecord(rec)
}
return table, nil
}
func restrictedRecords() (records []*eacl.Record) {
for op := eacl.OperationGet; op <= eacl.OperationRangeHash; op++ {
record := eacl.NewRecord()
record.SetOperation(op)
record.SetAction(eacl.ActionDeny)
eacl.AddFormedTarget(record, eacl.RoleOthers)
records = append(records, record)
}
return
}
func buildBearerToken(key *keys.PrivateKey, impersonate bool, table *eacl.Table, lifetime lifetimeOptions, gateKey *keys.PublicKey) (*bearer.Token, error) {
var ownerID user.ID
user.IDFromKey(&ownerID, (ecdsa.PublicKey)(*gateKey))
var bearerToken bearer.Token
if !impersonate {
bearerToken.SetEACLTable(*table)
}
bearerToken.ForUser(ownerID)
bearerToken.SetExp(lifetime.Exp)
bearerToken.SetIat(lifetime.Iat)
bearerToken.SetNbf(lifetime.Iat)
bearerToken.SetImpersonate(impersonate)
err := bearerToken.Sign(key.PrivateKey)
if err != nil {
return nil, fmt.Errorf("sign bearer token: %w", err)
}
return &bearerToken, nil
}
func buildBearerTokens(key *keys.PrivateKey, impersonate bool, table *eacl.Table, lifetime lifetimeOptions, gatesKeys []*keys.PublicKey) ([]*bearer.Token, error) {
bearerTokens := make([]*bearer.Token, 0, len(gatesKeys))
for _, gateKey := range gatesKeys {
tkn, err := buildBearerToken(key, impersonate, table, lifetime, gateKey)
if err != nil {
return nil, fmt.Errorf("build bearer token: %w", err)
}
bearerTokens = append(bearerTokens, tkn)
}
return bearerTokens, nil
}
func buildSessionToken(key *keys.PrivateKey, lifetime lifetimeOptions, ctx sessionTokenContext, gateKey *keys.PublicKey) (*session.Container, error) {
tok := new(session.Container)
tok.ForVerb(ctx.verb)
if !ctx.containerID.Equals(cid.ID{}) {
tok.ApplyOnlyTo(ctx.containerID)
}
tok.SetID(uuid.New())
tok.SetAuthKey((*frostfsecdsa.PublicKey)(gateKey))
tok.SetIat(lifetime.Iat)
tok.SetNbf(lifetime.Iat)
tok.SetExp(lifetime.Exp)
return tok, tok.Sign(key.PrivateKey)
}
func buildSessionTokens(key *keys.PrivateKey, lifetime lifetimeOptions, ctxs []sessionTokenContext, gatesKeys []*keys.PublicKey) ([][]*session.Container, error) {
sessionTokens := make([][]*session.Container, 0, len(gatesKeys))
for _, gateKey := range gatesKeys {
tkns := make([]*session.Container, len(ctxs))
for i, ctx := range ctxs {
tkn, err := buildSessionToken(key, lifetime, ctx, gateKey)
if err != nil {
return nil, fmt.Errorf("build session token: %w", err)
}
tkns[i] = tkn
}
sessionTokens = append(sessionTokens, tkns)
}
return sessionTokens, nil
}
func createTokens(options *IssueSecretOptions, lifetime lifetimeOptions) ([]*accessbox.GateData, error) {
gates := make([]*accessbox.GateData, len(options.GatesPublicKeys))
table, err := buildEACLTable(options.EACLRules)
if err != nil {
return nil, fmt.Errorf("failed to build eacl table: %w", err)
}
bearerTokens, err := buildBearerTokens(options.FrostFSKey, options.Impersonate, table, lifetime, options.GatesPublicKeys)
if err != nil {
return nil, fmt.Errorf("failed to build bearer tokens: %w", err)
}
for i, gateKey := range options.GatesPublicKeys {
gates[i] = accessbox.NewGateData(gateKey, bearerTokens[i])
}
if !options.SkipSessionRules {
sessionRules, err := buildContext(options.SessionTokenRules)
if err != nil {
return nil, fmt.Errorf("failed to build context for session token: %w", err)
}
sessionTokens, err := buildSessionTokens(options.FrostFSKey, lifetime, sessionRules, options.GatesPublicKeys)
if err != nil {
return nil, fmt.Errorf("failed to biuild session token: %w", err)
}
for i, sessionTkns := range sessionTokens {
gates[i].SessionTokens = sessionTkns
}
}
return gates, nil
}
func formTokensToUpdate(options tokenUpdateOptions) ([]*accessbox.GateData, error) {
btoken := options.box.Gate.BearerToken
table := btoken.EACLTable()
bearerTokens, err := buildBearerTokens(options.frostFSKey, btoken.Impersonate(), &table, options.lifetime, options.gatesPublicKeys)
if err != nil {
return nil, fmt.Errorf("failed to build bearer tokens: %w", err)
}
gates := make([]*accessbox.GateData, len(options.gatesPublicKeys))
for i, gateKey := range options.gatesPublicKeys {
gates[i] = accessbox.NewGateData(gateKey, bearerTokens[i])
}
sessionRules := make([]sessionTokenContext, len(options.box.Gate.SessionTokens))
for i, token := range options.box.Gate.SessionTokens {
var stoken sessionv2.Token
token.WriteToV2(&stoken)
sessionCtx, ok := stoken.GetBody().GetContext().(*sessionv2.ContainerSessionContext)
if !ok {
return nil, fmt.Errorf("get context from session token: %w", err)
}
var cnrID cid.ID
if cnrIDv2 := sessionCtx.ContainerID(); cnrIDv2 != nil {
if err = cnrID.ReadFromV2(*cnrIDv2); err != nil {
return nil, fmt.Errorf("read from v2 container id: %w", err)
}
}
sessionRules[i] = sessionTokenContext{
verb: session.ContainerVerb(sessionCtx.Verb()),
containerID: cnrID,
}
}
sessionTokens, err := buildSessionTokens(options.frostFSKey, options.lifetime, sessionRules, options.gatesPublicKeys)
if err != nil {
return nil, fmt.Errorf("failed to biuild session token: %w", err)
}
for i, sessionTkns := range sessionTokens {
gates[i].SessionTokens = sessionTkns
}
return gates, nil
}
func accessKeyIDFromAddr(addr oid.Address) string {
return addr.Container().EncodeToString() + "0" + addr.Object().EncodeToString()
}