node: Add APE chains to Bearer token #1157

Merged
fyrchik merged 4 commits from acid-ant/frostfs-node:feature/bearer-token-ape into master 2024-06-07 12:11:23 +00:00
14 changed files with 669 additions and 183 deletions

View file

@ -15,10 +15,12 @@ import (
eaclSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/eacl"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
const (
eaclFlag = "eacl"
apeFlag = "ape"
issuedAtFlag = "issued-at"
notValidBeforeFlag = "not-valid-before"
ownerFlag = "owner"
@ -37,10 +39,17 @@ In this case --` + commonflags.RPC + ` flag should be specified and the epoch in
is set to current epoch + n.
`,
Run: createToken,
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
ff := cmd.Flags()
_ = viper.BindPFlag(commonflags.WalletPath, ff.Lookup(commonflags.WalletPath))
_ = viper.BindPFlag(commonflags.Account, ff.Lookup(commonflags.Account))
},
}
func init() {
createCmd.Flags().StringP(eaclFlag, "e", "", "Path to the extended ACL table (mutually exclusive with --impersonate flag)")
createCmd.Flags().StringP(eaclFlag, "e", "", "Path to the extended ACL table (mutually exclusive with --impersonate and --ape flag)")
createCmd.Flags().StringP(apeFlag, "a", "", "Path to the JSON-encoded APE override (mutually exclusive with --impersonate and --eacl flag)")
createCmd.Flags().StringP(issuedAtFlag, "i", "+0", "Epoch to issue token at")
createCmd.Flags().StringP(notValidBeforeFlag, "n", "+0", "Not valid before epoch")
createCmd.Flags().StringP(commonflags.ExpireAt, "x", "", "The last active epoch for the token")
@ -49,10 +58,13 @@ func init() {
createCmd.Flags().Bool(jsonFlag, false, "Output token in JSON")
createCmd.Flags().Bool(impersonateFlag, false, "Mark token as impersonate to consider the token signer as the request owner (mutually exclusive with --eacl flag)")
createCmd.Flags().StringP(commonflags.RPC, commonflags.RPCShorthand, commonflags.RPCDefault, commonflags.RPCUsage)
createCmd.Flags().StringP(commonflags.WalletPath, commonflags.WalletPathShorthand, commonflags.WalletPathDefault, commonflags.WalletPathUsage)
createCmd.Flags().StringP(commonflags.Account, commonflags.AccountShorthand, commonflags.AccountDefault, commonflags.AccountUsage)
createCmd.MarkFlagsMutuallyExclusive(eaclFlag, impersonateFlag)
createCmd.MarkFlagsMutuallyExclusive(eaclFlag, apeFlag, impersonateFlag)
_ = cobra.MarkFlagFilename(createCmd.Flags(), eaclFlag)
_ = cobra.MarkFlagFilename(createCmd.Flags(), apeFlag)
_ = cobra.MarkFlagRequired(createCmd.Flags(), commonflags.ExpireAt)
_ = cobra.MarkFlagRequired(createCmd.Flags(), ownerFlag)
@ -119,6 +131,14 @@ func createToken(cmd *cobra.Command, _ []string) {
b.SetEACLTable(*table)
}
apePath, _ := cmd.Flags().GetString(apeFlag)
if apePath != "" {
var apeOverride bearer.APEOverride
raw, err := os.ReadFile(apePath)
commonCmd.ExitOnErr(cmd, "can't read APE rules: %w", err)
commonCmd.ExitOnErr(cmd, "can't parse APE rules: %w", json.Unmarshal(raw, &apeOverride))
b.SetAPEOverride(apeOverride)
}
var data []byte
toJSON, _ := cmd.Flags().GetBool(jsonFlag)

View file

@ -0,0 +1,115 @@
package bearer
import (
"errors"
"fmt"
"os"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/commonflags"
parseutil "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/modules/util"
commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common"
apeSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
cidSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
apechain "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
"github.com/spf13/cobra"
)
var (
errChainIDCannotBeEmpty = errors.New("chain id cannot be empty")
errRuleIsNotParsed = errors.New("rule is not passed")
)
const (
chainIDFlag = "chain-id"
chainIDHexFlag = "chain-id-hex"
ruleFlag = "rule"
pathFlag = "path"
outputFlag = "output"
)
var generateAPEOverrideCmd = &cobra.Command{
Use: "generate-ape-override",
Short: "Generate APE override.",
Long: `Generate APE override by target and APE chains. Util command.
Generated APE override can be dumped to a file in JSON format that is passed to
"create" command.
`,
Run: genereateAPEOverride,
}
func genereateAPEOverride(cmd *cobra.Command, _ []string) {
c := parseChain(cmd)
targetCID, _ := cmd.Flags().GetString(commonflags.CIDFlag)
var cid cidSDK.ID
commonCmd.ExitOnErr(cmd, "invalid cid format: %w", cid.DecodeString(targetCID))
override := &bearer.APEOverride{
Target: apeSDK.ChainTarget{
TargetType: apeSDK.TargetTypeContainer,
Name: targetCID,
},
Chains: []apeSDK.Chain{
{
Raw: c.Bytes(),
},
},
}
overrideMarshalled, err := override.MarshalJSON()
commonCmd.ExitOnErr(cmd, "failed to marshal APE override: %w", err)
outputPath, _ := cmd.Flags().GetString(outputFlag)
if outputPath != "" {
err := os.WriteFile(outputPath, []byte(overrideMarshalled), 0o644)
commonCmd.ExitOnErr(cmd, "dump error: %w", err)
} else {
fmt.Print("\n")
fmt.Println(string(overrideMarshalled))
}
}
func init() {
ff := generateAPEOverrideCmd.Flags()
ff.StringP(commonflags.CIDFlag, "", "", "Target container ID.")
_ = cobra.MarkFlagRequired(createCmd.Flags(), commonflags.CIDFlag)
ff.StringArray(ruleFlag, []string{}, "Rule statement")
ff.String(pathFlag, "", "Path to encoded chain in JSON or binary format")
ff.String(chainIDFlag, "", "Assign ID to the parsed chain")
ff.Bool(chainIDHexFlag, false, "Flag to parse chain ID as hex")
ff.String(outputFlag, "", "Output path to dump result JSON-encoded APE override")
_ = cobra.MarkFlagFilename(createCmd.Flags(), outputFlag)
}
func parseChainID(cmd *cobra.Command) apechain.ID {
chainID, _ := cmd.Flags().GetString(chainIDFlag)
if chainID == "" {
commonCmd.ExitOnErr(cmd, "read chain id error: %w",
errChainIDCannotBeEmpty)
}
return apechain.ID(chainID)
}
func parseChain(cmd *cobra.Command) *apechain.Chain {
chain := new(apechain.Chain)
if rules, _ := cmd.Flags().GetStringArray(ruleFlag); len(rules) > 0 {
commonCmd.ExitOnErr(cmd, "parser error: %w", parseutil.ParseAPEChain(chain, rules))
} else if encPath, _ := cmd.Flags().GetString(pathFlag); encPath != "" {
commonCmd.ExitOnErr(cmd, "decode binary or json error: %w", parseutil.ParseAPEChainBinaryOrJSON(chain, encPath))
} else {
commonCmd.ExitOnErr(cmd, "parser error: %w", errRuleIsNotParsed)
}
chain.ID = parseChainID(cmd)
cmd.Println("Parsed chain:")
parseutil.PrintHumanReadableAPEChain(cmd, chain)
return chain
}

View file

@ -11,4 +11,5 @@ var Cmd = &cobra.Command{
func init() {
Cmd.AddCommand(createCmd)
Cmd.AddCommand(generateAPEOverrideCmd)
}

View file

@ -473,6 +473,7 @@ func createAPEService(c *cfg, splitSvc *objectService.TransportSplitter) *object
objectAPE.NewStorageEngineHeaderProvider(c.cfgObject.cfgLocalStorage.localStorage, c.cfgObject.getSvc),
c.shared.frostfsidClient,
c.netMapSource,
c.cfgNetmap.state,
c.cfgObject.cnrSource,
c.binPublicKey,
),

View file

@ -66,6 +66,7 @@ func initTreeService(c *cfg) {
tree.WithAuthorizedKeys(treeConfig.AuthorizedKeys()),
tree.WithMetrics(c.metricsCollector.TreeService()),
tree.WithAPERouter(c.cfgObject.cfgAccessPolicyEngine.accessPolicyEngine),
tree.WithNetmapState(c.cfgNetmap.state),
)
c.cfgGRPC.performAndSave(func(_ string, _ net.Listener, s *grpc.Server) {

View file

@ -0,0 +1,55 @@
package router
import (
"fmt"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
cidSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
apechain "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
"git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine"
"git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine/inmemory"
)
func newTarget(ct ape.ChainTarget) (engine.Target, error) {
var target engine.Target
switch ct.TargetType {
case ape.TargetTypeContainer:
var cid cidSDK.ID
err := cid.DecodeString(ct.Name)
if err != nil {
return target, fmt.Errorf("invalid cid format: %s", target.Name)
}
target.Type = engine.Container
case ape.TargetTypeGroup:
target.Type = engine.Group
case ape.TargetTypeNamespace:
target.Type = engine.Namespace
case ape.TargetTypeUser:
target.Type = engine.User
default:
return target, fmt.Errorf("unsupported target type: %v", ct.TargetType)
}
target.Name = ct.Name
return target, nil
}
// SingleUseRouterWithBearerTokenChains creates chain router with inmemory storage implementation and
// fed with APE chains defined in Bearer token.
func SingleUseRouterWithBearerTokenChains(overrides []bearer.APEOverride) (engine.ChainRouter, error) {
storage := inmemory.NewInmemoryMorphRuleChainStorage()
for _, override := range overrides {
target, err := newTarget(override.Target)
if err != nil {
return nil, err
}
for i := range override.Chains {
chain := new(apechain.Chain)
if err := chain.DecodeBytes(override.Chains[i].Raw); err != nil {
return nil, fmt.Errorf("invalid ape chain: %w", err)
}
_, _, _ = storage.AddMorphRuleChain(apechain.Ingress, target, chain)
}
}
return engine.NewDefaultChainRouter(storage), nil
}

View file

@ -118,6 +118,7 @@ func (w *wrappedGetObjectStream) Context() context.Context {
SenderKey: w.requestInfo.SenderKey(),
Role: w.requestInfo.RequestRole(),
SoftAPECheck: w.requestInfo.IsSoftAPECheck(),
BearerToken: w.requestInfo.Bearer(),
})
}
@ -143,6 +144,7 @@ func (w *wrappedRangeStream) Context() context.Context {
SenderKey: w.requestInfo.SenderKey(),
Role: w.requestInfo.RequestRole(),
SoftAPECheck: w.requestInfo.IsSoftAPECheck(),
BearerToken: w.requestInfo.Bearer(),
})
}
@ -168,6 +170,7 @@ func (w *wrappedSearchStream) Context() context.Context {
SenderKey: w.requestInfo.SenderKey(),
Role: w.requestInfo.RequestRole(),
SoftAPECheck: w.requestInfo.IsSoftAPECheck(),
BearerToken: w.requestInfo.Bearer(),
})
}
@ -479,6 +482,7 @@ func requestContext(ctx context.Context, reqInfo RequestInfo) context.Context {
SenderKey: reqInfo.SenderKey(),
Role: reqInfo.RequestRole(),
SoftAPECheck: reqInfo.IsSoftAPECheck(),
BearerToken: reqInfo.Bearer(),
})
}

View file

@ -2,13 +2,17 @@ package ape
import (
"context"
"crypto/ecdsa"
"errors"
"fmt"
objectV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/router"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container"
frostfsidcore "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/frostfsid"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/netmap"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
@ -23,16 +27,18 @@ type checkerImpl struct {
headerProvider HeaderProvider
frostFSIDClient frostfsidcore.SubjectProvider
nm netmap.Source
st netmap.State
cnrSource container.Source
nodePK []byte
}
func NewChecker(chainRouter policyengine.ChainRouter, headerProvider HeaderProvider, frostFSIDClient frostfsidcore.SubjectProvider, nm netmap.Source, cnrSource container.Source, nodePK []byte) Checker {
func NewChecker(chainRouter policyengine.ChainRouter, headerProvider HeaderProvider, frostFSIDClient frostfsidcore.SubjectProvider, nm netmap.Source, st netmap.State, cnrSource container.Source, nodePK []byte) Checker {
return &checkerImpl{
chainRouter: chainRouter,
headerProvider: headerProvider,
frostFSIDClient: frostFSIDClient,
nm: nm,
st: st,
cnrSource: cnrSource,
nodePK: nodePK,
}
@ -67,9 +73,69 @@ type Prm struct {
// If true, object headers will not retrieved from storage engine.
WithoutHeaderRequest bool
// The request's bearer token. It is used in order to check APE overrides with the token.
BearerToken *bearer.Token
}
var errMissingOID = errors.New("object ID is not set")
var (
errMissingOID = errors.New("object ID is not set")
errInvalidTargetType = errors.New("bearer token defines non-container target override")
errBearerExpired = errors.New("bearer token has expired")
errBearerInvalidSignature = errors.New("bearer token has invalid signature")
errBearerInvalidContainerID = errors.New("bearer token was created for another container")
errBearerNotSignedByOwner = errors.New("bearer token is not signed by the container owner")
errBearerInvalidOwner = errors.New("bearer token owner differs from the request sender")
)
// isValidBearer checks whether bearer token was correctly signed by authorized
// entity. This method might be defined on whole ACL service because it will
// require fetching current epoch to check lifetime.
func isValidBearer(token *bearer.Token, ownerCnr user.ID, containerID cid.ID, publicKey *keys.PublicKey, st netmap.State) error {
if token == nil {
return nil
}
// 1. First check token lifetime. Simplest verification.
if token.InvalidAt(st.CurrentEpoch()) {
return errBearerExpired
}
// 2. Then check if bearer token is signed correctly.
if !token.VerifySignature() {
return errBearerInvalidSignature
}
// 3. Then check if container is either empty or equal to the container in the request.
apeOverride := token.APEOverride()
if apeOverride.Target.TargetType != ape.TargetTypeContainer {
return errInvalidTargetType
}
var targetCnr cid.ID
err := targetCnr.DecodeString(apeOverride.Target.Name)
if err != nil {
return fmt.Errorf("invalid cid format: %s", apeOverride.Target.Name)
}
if !containerID.Equals(targetCnr) {
return errBearerInvalidContainerID
}
// 4. Then check if container owner signed this token.
if !bearer.ResolveIssuer(*token).Equals(ownerCnr) {
return errBearerNotSignedByOwner
}
// 5. Then check if request sender has rights to use this token.
var usrSender user.ID
user.IDFromKey(&usrSender, (ecdsa.PublicKey)(*publicKey))
if !token.AssertUser(usrSender) {
return errBearerInvalidOwner
}
return nil
}
// CheckAPE checks if a request or a response is permitted creating an ape request and passing
// it to chain router.
@ -99,6 +165,26 @@ func (c *checkerImpl) CheckAPE(ctx context.Context, prm Prm) error {
return err
}
if prm.BearerToken != nil && !prm.BearerToken.Impersonate() {
if err := isValidBearer(prm.BearerToken, prm.ContainerOwner, prm.Container, pub, c.st); err != nil {
return fmt.Errorf("bearer token validation error: %w", err)
}
btRouter, err := router.SingleUseRouterWithBearerTokenChains([]bearer.APEOverride{prm.BearerToken.APEOverride()})
if err != nil {
return err
}
status, found, err := btRouter.IsAllowed(apechain.Ingress, policyengine.NewRequestTargetWithContainer(prm.Container.EncodeToString()), r)
if err != nil {
return err
}
if found && status == apechain.Allow {
return nil
}
if status != apechain.NoRuleFound {
return fmt.Errorf("bearer token: method %s: %s", prm.Method, status)
}
}
rt := policyengine.NewRequestTargetExtended(prm.Namespace, prm.Container.EncodeToString(), fmt.Sprintf("%s:%s", prm.Namespace, pub.Address()), nil)
status, ruleFound, err := c.chainRouter.IsAllowed(apechain.Ingress, rt, r)
if err != nil {

View file

@ -2,6 +2,7 @@ package ape
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"errors"
"fmt"
@ -12,6 +13,8 @@ import (
"git.frostfs.info/TrueCloudLab/frostfs-contract/frostfsid/client"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container"
frostfsidcore "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/frostfsid"
apeSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/checksum"
containerSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
@ -228,170 +231,249 @@ func (f *frostfsIDProviderMock) GetSubjectExtended(key util.Uint160) (*client.Su
return v, nil
}
var apeCheckTestCases = []struct {
name string
container string
object *string
methods []string
header testHeader
containerRules []chain.Rule
expectAPEErr bool
}{
{
name: "oid required requests are allowed",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
containerRules: []chain.Rule{
{
Status: chain.Allow,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
},
},
},
{
name: "oid optional requests are allowed",
container: containerID,
methods: methodsOptionalOID,
containerRules: []chain.Rule{
{
Status: chain.Allow,
Actions: chain.Actions{Names: methodsOptionalOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)},
},
},
},
},
{
name: "oid required requests are denied",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
containerRules: []chain.Rule{
{
Status: chain.AccessDenied,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
},
},
expectAPEErr: true,
},
{
name: "oid required requests are denied by an attribute",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
header: testHeader{
headerObjSDK: &headerObjectSDKParams{
attributes: []struct {
key string
val string
}{
{
key: "attr1",
val: "attribute_value",
},
},
},
fromHeaderProvider: true,
},
containerRules: []chain.Rule{
{
Status: chain.AccessDenied,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
Any: true,
Condition: []chain.Condition{
{
Op: chain.CondStringLike,
Kind: chain.KindResource,
Key: "attr1",
Value: "attribute*",
},
},
},
},
expectAPEErr: true,
},
{
name: "oid required requests are denied by sender",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
header: testHeader{
headerObjSDK: &headerObjectSDKParams{
attributes: []struct {
key string
val string
}{
{
key: "attr1",
val: "attribute_value",
},
},
},
fromHeaderProvider: true,
},
containerRules: []chain.Rule{
{
Status: chain.AccessDenied,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
Any: true,
Condition: []chain.Condition{
{
Op: chain.CondStringLike,
Kind: chain.KindRequest,
Key: nativeschema.PropertyKeyActorPublicKey,
Value: senderKey,
},
},
},
},
expectAPEErr: true,
},
{
name: "optional oid requests reached quota limit by an attribute",
container: containerID,
methods: methodsOptionalOID,
header: testHeader{
headerObjSDK: &headerObjectSDKParams{
payloadSize: 1000,
},
fromRequestResponseHeader: true,
},
containerRules: []chain.Rule{
{
Status: chain.QuotaLimitReached,
Actions: chain.Actions{Names: methodsOptionalOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)},
},
Any: true,
Condition: []chain.Condition{
{
Op: chain.CondStringEquals,
Kind: chain.KindResource,
Key: nativeschema.PropertyKeyObjectPayloadLength,
Value: "1000",
},
},
},
},
expectAPEErr: true,
},
}
type stMock struct{}
func (m *stMock) CurrentEpoch() uint64 {
return 8
}
func TestAPECheck_BearerTokenOverrides(t *testing.T) {
for _, test := range apeCheckTestCases {
t.Run(test.name, func(t *testing.T) {
chain := chain.Chain{
Rules: test.containerRules,
MatchType: chain.MatchTypeFirstMatch,
}
chainSDK := apeSDK.Chain{
Raw: chain.Bytes(),
}
bt := new(bearer.Token)
bt.SetIat(1)
bt.SetExp(10)
bt.SetAPEOverride(bearer.APEOverride{
Target: apeSDK.ChainTarget{
TargetType: apeSDK.TargetTypeContainer,
Name: test.container,
},
Chains: []apeSDK.Chain{chainSDK},
})
bt.Sign(senderPrivateKey.PrivateKey)
var cnrOwner user.ID
user.IDFromKey(&cnrOwner, (ecdsa.PublicKey)(*senderPrivateKey.PublicKey()))
for _, method := range test.methods {
t.Run(method, func(t *testing.T) {
headerProvider := newHeaderProviderMock()
frostfsidProvider := newFrostfsIDProviderMock(t)
cnr := newContainerIDSDK(t, test.container)
obj := newObjectIDSDK(t, test.object)
ls := inmemory.NewInmemoryLocalStorage()
ms := inmemory.NewInmemoryMorphRuleChainStorage()
r := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls)
checker := NewChecker(r, headerProvider, frostfsidProvider, nil, &stMock{}, nil, nil)
prm := Prm{
Method: method,
Container: cnr,
Object: obj,
Role: role,
ContainerOwner: cnrOwner,
SenderKey: senderKey,
BearerToken: bt,
}
var headerObjSDK *objectSDK.Object
if test.header.headerObjSDK != nil {
headerObjSDK = newHeaderObjectSDK(cnr, obj, test.header.headerObjSDK)
if test.header.fromHeaderProvider {
require.NotNil(t, obj, "oid is required if a header is expected to be found in header provider")
headerProvider.addHeader(cnr, *obj, headerObjSDK)
} else if test.header.fromRequestResponseHeader {
prm.Header = headerObjSDK.ToV2().GetHeader()
}
}
err := checker.CheckAPE(context.Background(), prm)
if test.expectAPEErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
})
}
}
func TestAPECheck(t *testing.T) {
for _, test := range []struct {
name string
container string
object *string
methods []string
header testHeader
containerRules []chain.Rule
expectAPEErr bool
}{
{
name: "oid required requests are allowed",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
containerRules: []chain.Rule{
{
Status: chain.Allow,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
},
},
},
{
name: "oid optional requests are allowed",
container: containerID,
methods: methodsOptionalOID,
containerRules: []chain.Rule{
{
Status: chain.Allow,
Actions: chain.Actions{Names: methodsOptionalOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)},
},
},
},
},
{
name: "oid required requests are denied",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
containerRules: []chain.Rule{
{
Status: chain.AccessDenied,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
},
},
expectAPEErr: true,
},
{
name: "oid required requests are denied by an attribute",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
header: testHeader{
headerObjSDK: &headerObjectSDKParams{
attributes: []struct {
key string
val string
}{
{
key: "attr1",
val: "attribute_value",
},
},
},
fromHeaderProvider: true,
},
containerRules: []chain.Rule{
{
Status: chain.AccessDenied,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
Any: true,
Condition: []chain.Condition{
{
Op: chain.CondStringLike,
Kind: chain.KindResource,
Key: "attr1",
Value: "attribute*",
},
},
},
},
expectAPEErr: true,
},
{
name: "oid required requests are denied by sender",
container: containerID,
object: stringPtr(objectID),
methods: methodsRequiredOID,
header: testHeader{
headerObjSDK: &headerObjectSDKParams{
attributes: []struct {
key string
val string
}{
{
key: "attr1",
val: "attribute_value",
},
},
},
fromHeaderProvider: true,
},
containerRules: []chain.Rule{
{
Status: chain.AccessDenied,
Actions: chain.Actions{Names: methodsRequiredOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
},
Any: true,
Condition: []chain.Condition{
{
Op: chain.CondStringLike,
Kind: chain.KindRequest,
Key: nativeschema.PropertyKeyActorPublicKey,
Value: senderKey,
},
},
},
},
expectAPEErr: true,
},
{
name: "optional oid requests reached quota limit by an attribute",
container: containerID,
methods: methodsOptionalOID,
header: testHeader{
headerObjSDK: &headerObjectSDKParams{
payloadSize: 1000,
},
fromRequestResponseHeader: true,
},
containerRules: []chain.Rule{
{
Status: chain.QuotaLimitReached,
Actions: chain.Actions{Names: methodsOptionalOID},
Resources: chain.Resources{
Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)},
},
Any: true,
Condition: []chain.Condition{
{
Op: chain.CondStringEquals,
Kind: chain.KindResource,
Key: nativeschema.PropertyKeyObjectPayloadLength,
Value: "1000",
},
},
},
},
expectAPEErr: true,
},
} {
for _, test := range apeCheckTestCases {
t.Run(test.name, func(t *testing.T) {
for _, method := range test.methods {
t.Run(method, func(t *testing.T) {
@ -411,7 +493,7 @@ func TestAPECheck(t *testing.T) {
router := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls)
checker := NewChecker(router, headerProvider, frostfsidProvider, nil, nil, nil)
checker := NewChecker(router, headerProvider, frostfsidProvider, nil, &stMock{}, nil, nil)
prm := Prm{
Method: method,
@ -544,7 +626,7 @@ func TestPutECChunk(t *testing.T) {
},
}
checker := NewChecker(router, headerProvider, frostfsidProvider, nm, cs, node1Key.PublicKey().Bytes())
checker := NewChecker(router, headerProvider, frostfsidProvider, nm, &stMock{}, cs, node1Key.PublicKey().Bytes())
ecParentID := oidtest.ID()
chunkHeader := newHeaderObjectSDK(cnr, obj, nil).ToV2().GetHeader()
@ -586,7 +668,7 @@ func TestPutECChunk(t *testing.T) {
t.Run("access allowed for non container node", func(t *testing.T) {
otherKey, err := keys.NewPrivateKey()
require.NoError(t, err)
checker = NewChecker(router, headerProvider, frostfsidProvider, nm, cs, otherKey.PublicKey().Bytes())
checker = NewChecker(router, headerProvider, frostfsidProvider, nm, &stMock{}, cs, otherKey.PublicKey().Bytes())
prm := Prm{
Method: nativeschema.MethodPutObject,
Container: cnr,

View file

@ -13,6 +13,7 @@ import (
getsvc "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/get"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/util"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util/logger"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
@ -88,6 +89,8 @@ type getStreamBasicChecker struct {
role string
softAPECheck bool
bearerToken *bearer.Token
}
func (g *getStreamBasicChecker) Send(resp *objectV2.GetResponse) error {
@ -107,6 +110,7 @@ func (g *getStreamBasicChecker) Send(resp *objectV2.GetResponse) error {
ContainerOwner: g.containerOwner,
Role: g.role,
SoftAPECheck: g.softAPECheck,
BearerToken: g.bearerToken,
}
if err := g.apeChecker.CheckAPE(g.Context(), prm); err != nil {
@ -149,6 +153,7 @@ func (c *Service) Get(request *objectV2.GetRequest, stream objectSvc.GetObjectSt
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
WithoutHeaderRequest: true,
BearerToken: reqCtx.BearerToken,
})
if err != nil {
return toStatusErr(err)
@ -161,6 +166,7 @@ func (c *Service) Get(request *objectV2.GetRequest, stream objectSvc.GetObjectSt
senderKey: reqCtx.SenderKey,
role: nativeSchemaRole(reqCtx.Role),
softAPECheck: reqCtx.SoftAPECheck,
bearerToken: reqCtx.BearerToken,
})
}
@ -192,6 +198,7 @@ func (p *putStreamBasicChecker) Send(ctx context.Context, request *objectV2.PutR
ContainerOwner: reqCtx.ContainerOwner,
Role: nativeSchemaRole(reqCtx.Role),
SoftAPECheck: reqCtx.SoftAPECheck,
BearerToken: reqCtx.BearerToken,
}
if err := p.apeChecker.CheckAPE(ctx, prm); err != nil {
@ -236,6 +243,7 @@ func (c *Service) Head(ctx context.Context, request *objectV2.HeadRequest) (*obj
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
WithoutHeaderRequest: true,
BearerToken: reqCtx.BearerToken,
})
if err != nil {
return nil, toStatusErr(err)
@ -275,6 +283,7 @@ func (c *Service) Head(ctx context.Context, request *objectV2.HeadRequest) (*obj
SenderKey: hex.EncodeToString(reqCtx.SenderKey),
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
BearerToken: reqCtx.BearerToken,
})
if err != nil {
return nil, toStatusErr(err)
@ -303,6 +312,7 @@ func (c *Service) Search(request *objectV2.SearchRequest, stream objectSvc.Searc
SenderKey: hex.EncodeToString(reqCtx.SenderKey),
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
BearerToken: reqCtx.BearerToken,
})
if err != nil {
return toStatusErr(err)
@ -331,6 +341,7 @@ func (c *Service) Delete(ctx context.Context, request *objectV2.DeleteRequest) (
SenderKey: hex.EncodeToString(reqCtx.SenderKey),
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
BearerToken: reqCtx.BearerToken,
})
if err != nil {
return nil, toStatusErr(err)
@ -364,6 +375,7 @@ func (c *Service) GetRange(request *objectV2.GetRangeRequest, stream objectSvc.G
SenderKey: hex.EncodeToString(reqCtx.SenderKey),
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
BearerToken: reqCtx.BearerToken,
})
if err != nil {
return toStatusErr(err)
@ -392,6 +404,7 @@ func (c *Service) GetRangeHash(ctx context.Context, request *objectV2.GetRangeHa
SenderKey: hex.EncodeToString(reqCtx.SenderKey),
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
BearerToken: reqCtx.BearerToken,
}
if err = c.apeChecker.CheckAPE(ctx, prm); err != nil {
@ -430,6 +443,7 @@ func (c *Service) PutSingle(ctx context.Context, request *objectV2.PutSingleRequ
SenderKey: hex.EncodeToString(reqCtx.SenderKey),
ContainerOwner: reqCtx.ContainerOwner,
SoftAPECheck: reqCtx.SoftAPECheck,
BearerToken: reqCtx.BearerToken,
}
if err = c.apeChecker.CheckAPE(ctx, prm); err != nil {

View file

@ -1,6 +1,7 @@
package object
import (
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/acl"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
)
@ -20,4 +21,6 @@ type RequestContext struct {
Role acl.Role
SoftAPECheck bool
BearerToken *bearer.Token
}

View file

@ -2,18 +2,25 @@ package tree
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"errors"
"fmt"
"net"
"strings"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/converter"
aperequest "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/request"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/router"
core "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container"
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/netmap"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status"
cnrSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/acl"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
apechain "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
"git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine"
commonschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/common"
@ -22,20 +29,25 @@ import (
"google.golang.org/grpc/peer"
)
func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid cid.ID, operation acl.Op, role acl.Role, publicKey *keys.PublicKey) error {
namespace := ""
cntNamespace, hasNamespace := strings.CutSuffix(cnrSDK.ReadDomain(container.Value).Zone(), ".ns")
if hasNamespace {
namespace = cntNamespace
}
var (
errInvalidTargetType = errors.New("bearer token defines non-container target override")
errBearerExpired = errors.New("bearer token has expired")
errBearerInvalidSignature = errors.New("bearer token has invalid signature")
errBearerInvalidContainerID = errors.New("bearer token was created for another container")
errBearerNotSignedByOwner = errors.New("bearer token is not signed by the container owner")
errBearerInvalidOwner = errors.New("bearer token owner differs from the request sender")
)
func (s *Service) newAPERequest(ctx context.Context, namespace string,
cid cid.ID, operation acl.Op, role acl.Role, publicKey *keys.PublicKey,
) (aperequest.Request, error) {
schemaMethod, err := converter.SchemaMethodFromACLOperation(operation)
if err != nil {
return apeErr(err)
return aperequest.Request{}, err
}
schemaRole, err := converter.SchemaRoleFromACLRole(role)
if err != nil {
return apeErr(err)
return aperequest.Request{}, err
}
reqProps := map[string]string{
nativeschema.PropertyKeyActorPublicKey: hex.EncodeToString(publicKey.Bytes()),
@ -43,7 +55,7 @@ func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid c
}
reqProps, err = s.fillWithUserClaimTags(reqProps, publicKey)
if err != nil {
return apeErr(err)
return aperequest.Request{}, err
}
if p, ok := peer.FromContext(ctx); ok {
if tcpAddr, ok := p.Addr.(*net.TCPAddr); ok {
@ -58,11 +70,96 @@ func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid c
resourceName = fmt.Sprintf(nativeschema.ResourceFormatNamespaceContainerObjects, namespace, cid.EncodeToString())
}
request := aperequest.NewRequest(
return aperequest.NewRequest(
schemaMethod,
aperequest.NewResource(resourceName, make(map[string]string)),
reqProps,
)
), nil
}
// isValidBearer checks whether bearer token was correctly signed by authorized
// entity. This method might be defined on whole ACL service because it will
// require fetching current epoch to check lifetime.
func isValidBearer(token *bearer.Token, ownerCnr user.ID, cntID cid.ID, publicKey *keys.PublicKey, st netmap.State) error {
if token == nil {
return nil
}
// 1. First check token lifetime. Simplest verification.
if token.InvalidAt(st.CurrentEpoch()) {
return errBearerExpired
}
// 2. Then check if bearer token is signed correctly.
if !token.VerifySignature() {
return errBearerInvalidSignature
}
// 3. Then check if container is either empty or equal to the container in the request.
apeOverride := token.APEOverride()
if apeOverride.Target.TargetType != ape.TargetTypeContainer {
return errInvalidTargetType
}
var targetCnr cid.ID
err := targetCnr.DecodeString(apeOverride.Target.Name)
if err != nil {
return fmt.Errorf("invalid cid format: %s", apeOverride.Target.Name)
}
if !cntID.Equals(targetCnr) {
return errBearerInvalidContainerID
}
// 4. Then check if container owner signed this token.
if !bearer.ResolveIssuer(*token).Equals(ownerCnr) {
return errBearerNotSignedByOwner
}
// 5. Then check if request sender has rights to use this token.
var usrSender user.ID
user.IDFromKey(&usrSender, (ecdsa.PublicKey)(*publicKey))
if !token.AssertUser(usrSender) {
return errBearerInvalidOwner
}
return nil
}
func (s *Service) checkAPE(ctx context.Context, bt *bearer.Token,
container *core.Container, cid cid.ID, operation acl.Op, role acl.Role, publicKey *keys.PublicKey,
) error {
namespace := ""
cntNamespace, hasNamespace := strings.CutSuffix(cnrSDK.ReadDomain(container.Value).Zone(), ".ns")
if hasNamespace {
namespace = cntNamespace
}
request, err := s.newAPERequest(ctx, namespace, cid, operation, role, publicKey)
if err != nil {
return apeErr(err)
}
if bt != nil && !bt.Impersonate() {
if err := isValidBearer(bt, container.Value.Owner(), cid, publicKey, s.state); err != nil {
return fmt.Errorf("bearer validation error: %w", err)
}
btRouter, err := router.SingleUseRouterWithBearerTokenChains([]bearer.APEOverride{bt.APEOverride()})
if err != nil {
return apeErr(err)
}
status, found, err := btRouter.IsAllowed(apechain.Ingress, engine.NewRequestTargetWithContainer(cid.EncodeToString()), request)
if err != nil {
return apeErr(err)
}
if found && status == apechain.Allow {
return nil
}
if status != apechain.NoRuleFound {
err = fmt.Errorf("access to operation %s is denied by access policy engine (bearer token): %s", request.Operation(), status.String())
return apeErr(err)
}
}
rt := engine.NewRequestTargetExtended(namespace, cid.EncodeToString(), fmt.Sprintf("%s:%s", namespace, publicKey.Address()), nil)
status, found, err := s.router.IsAllowed(apechain.Ingress, rt, request)
@ -72,7 +169,7 @@ func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid c
if found && status == apechain.Allow {
return nil
}
err = fmt.Errorf("access to operation %s is denied by access policy engine: %s", schemaMethod, status.String())
err = fmt.Errorf("access to operation %s is denied by access policy engine: %s", request.Operation(), status.String())
return apeErr(err)
}

View file

@ -29,6 +29,7 @@ type cfg struct {
log *logger.Logger
key *ecdsa.PrivateKey
rawPub []byte
state netmap.State
nmSource netmap.Source
cnrSource ContainerSource
frostfsidSubjectProvider frostfsidcore.SubjectProvider
@ -156,3 +157,9 @@ func WithAPERouter(router policyengine.ChainRouter) Option {
c.router = router
}
}
func WithNetmapState(state netmap.State) Option {
return func(c *cfg) {
c.state = state
}
}

View file

@ -84,7 +84,7 @@ func (s *Service) verifyClient(ctx context.Context, req message, cid cidSDK.ID,
// FIXME(@aarifullin): tree service temporiraly performs APE checks on
// object verbs, because tree verbs have not been introduced yet.
if basicACL == 0x0 {
return s.checkAPE(ctx, cnr, cid, op, role, pubKey)
return s.checkAPE(ctx, bt, cnr, cid, op, role, pubKey)
}
if !basicACL.IsOpAllowed(op, role) {