diff --git a/cmd/frostfs-node/object.go b/cmd/frostfs-node/object.go
index 2d18f82a3..5c82f0dcc 100644
--- a/cmd/frostfs-node/object.go
+++ b/cmd/frostfs-node/object.go
@@ -24,6 +24,7 @@ import (
 	objectService "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object"
 	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/acl"
 	v2 "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/acl/v2"
+	objectAPE "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/ape"
 	deletesvc "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/delete"
 	deletesvcV2 "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/delete/v2"
 	getsvc "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/get"
@@ -183,11 +184,13 @@ func initObjectService(c *cfg) {
 	sDeleteV2 := createDeleteServiceV2(sDelete)
 
 	// build service pipeline
-	// grpc | <metrics> | signature | response | acl | split
+	// grpc | <metrics> | signature | response | acl | ape | split
 
 	splitSvc := createSplitService(c, sPutV2, sGetV2, sSearchV2, sDeleteV2)
 
-	aclSvc := createACLServiceV2(c, splitSvc, &irFetcher)
+	apeSvc := createAPEService(c, splitSvc)
+
+	aclSvc := createACLServiceV2(c, apeSvc, &irFetcher)
 
 	var commonSvc objectService.Common
 	commonSvc.Init(&c.internals, aclSvc)
@@ -419,11 +422,11 @@ func createSplitService(c *cfg, sPutV2 *putsvcV2.Service, sGetV2 *getsvcV2.Servi
 	)
 }
 
-func createACLServiceV2(c *cfg, splitSvc *objectService.TransportSplitter, irFetcher *cachedIRFetcher) v2.Service {
+func createACLServiceV2(c *cfg, apeSvc *objectAPE.Service, irFetcher *cachedIRFetcher) v2.Service {
 	ls := c.cfgObject.cfgLocalStorage.localStorage
 
 	return v2.New(
-		splitSvc,
+		apeSvc,
 		c.netMapSource,
 		irFetcher,
 		acl.NewChecker(
@@ -431,12 +434,22 @@ func createACLServiceV2(c *cfg, splitSvc *objectService.TransportSplitter, irFet
 			c.cfgObject.eaclSource,
 			eaclSDK.NewValidator(),
 			ls),
-		acl.NewAPEChecker(c.log, c.cfgObject.cfgAccessPolicyEngine.accessPolicyEngine.chainRouter),
 		c.cfgObject.cnrSource,
 		v2.WithLogger(c.log),
 	)
 }
 
+func createAPEService(c *cfg, splitSvc *objectService.TransportSplitter) *objectAPE.Service {
+	return objectAPE.NewService(
+		c.log,
+		objectAPE.NewChecker(
+			c.cfgObject.cfgAccessPolicyEngine.accessPolicyEngine.chainRouter,
+			objectAPE.NewStorageEngineHeaderProvider(c.cfgObject.cfgLocalStorage.localStorage),
+		),
+		splitSvc,
+	)
+}
+
 type morphEACLFetcher struct {
 	w *cntClient.Client
 }
diff --git a/pkg/services/object/acl/ape.go b/pkg/services/object/acl/ape.go
deleted file mode 100644
index a431ad252..000000000
--- a/pkg/services/object/acl/ape.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package acl
-
-import (
-	"fmt"
-
-	v2 "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/acl/v2"
-	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util/logger"
-	apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status"
-	apechain "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
-	policyengine "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine"
-)
-
-type apeCheckerImpl struct {
-	log         *logger.Logger
-	chainRouter policyengine.ChainRouter
-}
-
-func NewAPEChecker(log *logger.Logger, chainRouter policyengine.ChainRouter) v2.APEChainChecker {
-	return &apeCheckerImpl{
-		log:         log,
-		chainRouter: chainRouter,
-	}
-}
-
-func (c *apeCheckerImpl) CheckIfRequestPermitted(reqInfo v2.RequestInfo) error {
-	request := new(Request)
-	request.FromRequestInfo(reqInfo)
-
-	cnrTarget := reqInfo.ContainerID().EncodeToString()
-
-	status, ruleFound, err := c.chainRouter.IsAllowed(apechain.Ingress, policyengine.NewRequestTargetWithContainer(cnrTarget), request)
-	if err != nil {
-		return err
-	}
-
-	if !ruleFound || status == apechain.Allow {
-		return nil
-	}
-
-	return apeErr(reqInfo, status)
-}
-
-const accessDeniedAPEReasonFmt = "access to operation %s is denied by access policy engine: %s"
-
-func apeErr(req v2.RequestInfo, status apechain.Status) error {
-	errAccessDenied := &apistatus.ObjectAccessDenied{}
-	errAccessDenied.WriteReason(fmt.Sprintf(accessDeniedAPEReasonFmt, req.Operation(), status.String()))
-	return errAccessDenied
-}
diff --git a/pkg/services/object/acl/ape_request.go b/pkg/services/object/acl/ape_request.go
deleted file mode 100644
index accbdce5f..000000000
--- a/pkg/services/object/acl/ape_request.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package acl
-
-import (
-	"fmt"
-
-	v2 "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/acl/v2"
-	aclSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/acl"
-	aperesource "git.frostfs.info/TrueCloudLab/policy-engine/pkg/resource"
-	nativeschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/native"
-)
-
-type Request struct {
-	operation  string
-	resource   *resource
-	properties map[string]string
-}
-
-var _ aperesource.Request = (*Request)(nil)
-
-type resource struct {
-	name       string
-	properties map[string]string
-}
-
-var _ aperesource.Resource = (*resource)(nil)
-
-func (r *resource) Name() string {
-	return r.name
-}
-
-func (r *resource) Property(key string) string {
-	return r.properties[key]
-}
-
-func getResource(reqInfo v2.RequestInfo) *resource {
-	var name string
-	cid := reqInfo.ContainerID()
-	if oid := reqInfo.ObjectID(); oid != nil {
-		name = fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, cid.EncodeToString(), oid.EncodeToString())
-	} else {
-		name = fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, cid.EncodeToString())
-	}
-	return &resource{
-		name:       name,
-		properties: make(map[string]string),
-	}
-}
-
-func getProperties(_ v2.RequestInfo) map[string]string {
-	return map[string]string{
-		nativeschema.PropertyKeyActorPublicKey: "",
-		nativeschema.PropertyKeyActorRole:      "",
-	}
-}
-
-func getOperation(reqInfo v2.RequestInfo) string {
-	switch op := reqInfo.Operation(); op {
-	case aclSDK.OpObjectGet:
-		return nativeschema.MethodGetObject
-	case aclSDK.OpObjectHead:
-		return nativeschema.MethodHeadObject
-	case aclSDK.OpObjectPut:
-		return nativeschema.MethodPutObject
-	case aclSDK.OpObjectDelete:
-		return nativeschema.MethodDeleteObject
-	case aclSDK.OpObjectSearch:
-		return nativeschema.MethodSearchObject
-	case aclSDK.OpObjectRange:
-		return nativeschema.MethodRangeObject
-	case aclSDK.OpObjectHash:
-		return nativeschema.MethodHashObject
-	default:
-		return ""
-	}
-}
-
-func NewRequest() *Request {
-	return &Request{
-		resource:   new(resource),
-		properties: map[string]string{},
-	}
-}
-
-func (r *Request) FromRequestInfo(ri v2.RequestInfo) {
-	r.operation = getOperation(ri)
-	r.resource = getResource(ri)
-	r.properties = getProperties(ri)
-}
-
-func (r *Request) Operation() string {
-	return r.operation
-}
-
-func (r *Request) Property(key string) string {
-	return r.properties[key]
-}
-
-func (r *Request) Resource() aperesource.Resource {
-	return r.resource
-}
diff --git a/pkg/services/object/acl/v2/request.go b/pkg/services/object/acl/v2/request.go
index 675768969..c58c00e0e 100644
--- a/pkg/services/object/acl/v2/request.go
+++ b/pkg/services/object/acl/v2/request.go
@@ -22,6 +22,9 @@ type RequestInfo struct {
 	operation   acl.Op  // put, get, head, etc.
 	cnrOwner    user.ID // container owner
 
+	// cnrNamespace defined to which namespace a container is belonged.
+	cnrNamespace string
+
 	idCnr cid.ID
 
 	// optional for some request
@@ -57,6 +60,10 @@ func (r RequestInfo) ContainerOwner() user.ID {
 	return r.cnrOwner
 }
 
+func (r RequestInfo) ContainerNamespace() string {
+	return r.cnrNamespace
+}
+
 // ObjectID return object ID.
 func (r RequestInfo) ObjectID() *oid.ID {
 	return r.obj
diff --git a/pkg/services/object/acl/v2/service.go b/pkg/services/object/acl/v2/service.go
index ee167d0e4..ed077411c 100644
--- a/pkg/services/object/acl/v2/service.go
+++ b/pkg/services/object/acl/v2/service.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 
+	containerV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/container"
 	objectV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object"
 	"git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/session"
 	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container"
@@ -67,10 +68,6 @@ type cfg struct {
 
 	checker ACLChecker
 
-	// TODO(aarifullin): apeCheck is temporarily the part of
-	// acl service and must be standalone.
-	apeChecker APEChainChecker
-
 	irFetcher InnerRingFetcher
 
 	nm netmap.Source
@@ -83,7 +80,6 @@ func New(next object.ServiceServer,
 	nm netmap.Source,
 	irf InnerRingFetcher,
 	acl ACLChecker,
-	apeChecker APEChainChecker,
 	cs container.Source,
 	opts ...Option,
 ) Service {
@@ -93,7 +89,6 @@ func New(next object.ServiceServer,
 		nm:         nm,
 		irFetcher:  irf,
 		checker:    acl,
-		apeChecker: apeChecker,
 		containers: cs,
 	}
 
@@ -107,6 +102,75 @@ func New(next object.ServiceServer,
 	}
 }
 
+// wrappedGetObjectStream propagates RequestContext into GetObjectStream's context.
+// This allows to retrieve already calculated immutable request-specific values in next handler invocation.
+type wrappedGetObjectStream struct {
+	object.GetObjectStream
+
+	requestInfo RequestInfo
+}
+
+func (w *wrappedGetObjectStream) Context() context.Context {
+	return context.WithValue(w.GetObjectStream.Context(), object.RequestContextKey, &object.RequestContext{
+		Namespace: w.requestInfo.ContainerNamespace(),
+		SenderKey: w.requestInfo.SenderKey(),
+		Role:      w.requestInfo.RequestRole(),
+	})
+}
+
+func newWrappedGetObjectStreamStream(getObjectStream object.GetObjectStream, reqInfo RequestInfo) object.GetObjectStream {
+	return &wrappedGetObjectStream{
+		GetObjectStream: getObjectStream,
+		requestInfo:     reqInfo,
+	}
+}
+
+// wrappedRangeStream propagates RequestContext into GetObjectRangeStream's context.
+// This allows to retrieve already calculated immutable request-specific values in next handler invocation.
+type wrappedRangeStream struct {
+	object.GetObjectRangeStream
+
+	requestInfo RequestInfo
+}
+
+func (w *wrappedRangeStream) Context() context.Context {
+	return context.WithValue(w.GetObjectRangeStream.Context(), object.RequestContextKey, &object.RequestContext{
+		Namespace: w.requestInfo.ContainerNamespace(),
+		SenderKey: w.requestInfo.SenderKey(),
+		Role:      w.requestInfo.RequestRole(),
+	})
+}
+
+func newWrappedRangeStream(rangeStream object.GetObjectRangeStream, reqInfo RequestInfo) object.GetObjectRangeStream {
+	return &wrappedRangeStream{
+		GetObjectRangeStream: rangeStream,
+		requestInfo:          reqInfo,
+	}
+}
+
+// wrappedSearchStream propagates RequestContext into SearchStream's context.
+// This allows to retrieve already calculated immutable request-specific values in next handler invocation.
+type wrappedSearchStream struct {
+	object.SearchStream
+
+	requestInfo RequestInfo
+}
+
+func (w *wrappedSearchStream) Context() context.Context {
+	return context.WithValue(w.SearchStream.Context(), object.RequestContextKey, &object.RequestContext{
+		Namespace: w.requestInfo.ContainerNamespace(),
+		SenderKey: w.requestInfo.SenderKey(),
+		Role:      w.requestInfo.RequestRole(),
+	})
+}
+
+func newWrappedSearchStream(searchStream object.SearchStream, reqInfo RequestInfo) object.SearchStream {
+	return &wrappedSearchStream{
+		SearchStream: searchStream,
+		requestInfo:  reqInfo,
+	}
+}
+
 // Get implements ServiceServer interface, makes ACL checks and calls
 // next Get method in the ServiceServer pipeline.
 func (b Service) Get(request *objectV2.GetRequest, stream object.GetObjectStream) error {
@@ -158,7 +222,7 @@ func (b Service) Get(request *objectV2.GetRequest, stream object.GetObjectStream
 	}
 
 	return b.next.Get(request, &getStreamBasicChecker{
-		GetObjectStream: stream,
+		GetObjectStream: newWrappedGetObjectStreamStream(stream, reqInfo),
 		info:            reqInfo,
 		checker:         b.checker,
 	})
@@ -224,7 +288,7 @@ func (b Service) Head(
 		return nil, eACLErr(reqInfo, err)
 	}
 
-	resp, err := b.next.Head(ctx, request)
+	resp, err := b.next.Head(requestContext(ctx, reqInfo), request)
 	if err == nil {
 		if err = b.checker.CheckEACL(resp, reqInfo); err != nil {
 			err = eACLErr(reqInfo, err)
@@ -277,7 +341,7 @@ func (b Service) Search(request *objectV2.SearchRequest, stream object.SearchStr
 
 	return b.next.Search(request, &searchStreamBasicChecker{
 		checker:      b.checker,
-		SearchStream: stream,
+		SearchStream: newWrappedSearchStream(stream, reqInfo),
 		info:         reqInfo,
 	})
 }
@@ -333,7 +397,7 @@ func (b Service) Delete(
 		return nil, eACLErr(reqInfo, err)
 	}
 
-	return b.next.Delete(ctx, request)
+	return b.next.Delete(requestContext(ctx, reqInfo), request)
 }
 
 func (b Service) GetRange(request *objectV2.GetRangeRequest, stream object.GetObjectRangeStream) error {
@@ -386,11 +450,18 @@ func (b Service) GetRange(request *objectV2.GetRangeRequest, stream object.GetOb
 
 	return b.next.GetRange(request, &rangeStreamBasicChecker{
 		checker:              b.checker,
-		GetObjectRangeStream: stream,
+		GetObjectRangeStream: newWrappedRangeStream(stream, reqInfo),
 		info:                 reqInfo,
 	})
 }
 
+func requestContext(ctx context.Context, reqInfo RequestInfo) context.Context {
+	return context.WithValue(ctx, object.RequestContextKey, &object.RequestContext{
+		SenderKey: reqInfo.SenderKey(),
+		Role:      reqInfo.RequestRole(),
+	})
+}
+
 func (b Service) GetRangeHash(
 	ctx context.Context,
 	request *objectV2.GetRangeHashRequest,
@@ -442,7 +513,7 @@ func (b Service) GetRangeHash(
 		return nil, eACLErr(reqInfo, err)
 	}
 
-	return b.next.GetRangeHash(ctx, request)
+	return b.next.GetRangeHash(requestContext(ctx, reqInfo), request)
 }
 
 func (b Service) PutSingle(ctx context.Context, request *objectV2.PutSingleRequest) (*objectV2.PutSingleResponse, error) {
@@ -501,7 +572,7 @@ func (b Service) PutSingle(ctx context.Context, request *objectV2.PutSingleReque
 		return nil, eACLErr(reqInfo, err)
 	}
 
-	return b.next.PutSingle(ctx, request)
+	return b.next.PutSingle(requestContext(ctx, reqInfo), request)
 }
 
 func (p putStreamBasicChecker) Send(ctx context.Context, request *objectV2.PutRequest) error {
@@ -566,9 +637,11 @@ func (p putStreamBasicChecker) Send(ctx context.Context, request *objectV2.PutRe
 
 		reqInfo.obj = obj
 
-		if err := p.source.apeChecker.CheckIfRequestPermitted(reqInfo); err != nil {
-			return err
+		if !p.source.checker.CheckBasicACL(reqInfo) || !p.source.checker.StickyBitCheck(reqInfo, idOwner) {
+			return basicACLErr(reqInfo)
 		}
+
+		ctx = requestContext(ctx, reqInfo)
 	}
 
 	return p.next.Send(ctx, request)
@@ -671,6 +744,7 @@ func (b Service) findRequestInfo(req MetaWithToken, idCnr cid.ID, op acl.Op) (in
 	info.operation = op
 	info.cnrOwner = cnr.Value.Owner()
 	info.idCnr = idCnr
+	info.cnrNamespace = cnr.Value.Attribute(containerV2.SysAttributeZone)
 
 	// it is assumed that at the moment the key will be valid,
 	// otherwise the request would not pass validation
diff --git a/pkg/services/object/acl/v2/types.go b/pkg/services/object/acl/v2/types.go
index a113c4693..061cd26b6 100644
--- a/pkg/services/object/acl/v2/types.go
+++ b/pkg/services/object/acl/v2/types.go
@@ -26,9 +26,3 @@ type InnerRingFetcher interface {
 	// the actual inner ring.
 	InnerRingKeys() ([][]byte, error)
 }
-
-// APEChainChecker is the interface that provides methods to
-// check if the access policy engine permits to perform the request.
-type APEChainChecker interface {
-	CheckIfRequestPermitted(RequestInfo) error
-}
diff --git a/pkg/services/object/ape/checker.go b/pkg/services/object/ape/checker.go
new file mode 100644
index 000000000..fdaa9df85
--- /dev/null
+++ b/pkg/services/object/ape/checker.go
@@ -0,0 +1,82 @@
+package ape
+
+import (
+	"context"
+	"fmt"
+
+	objectV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object"
+	apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status"
+	cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
+	oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
+	apechain "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
+	policyengine "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine"
+)
+
+type checkerImpl struct {
+	chainRouter policyengine.ChainRouter
+
+	headerProvider HeaderProvider
+}
+
+func NewChecker(chainRouter policyengine.ChainRouter, headerProvider HeaderProvider) Checker {
+	return &checkerImpl{
+		chainRouter: chainRouter,
+
+		headerProvider: headerProvider,
+	}
+}
+
+type Prm struct {
+	Namespace string
+
+	Container cid.ID
+
+	// Object ID is omitted for some methods.
+	Object *oid.ID
+
+	// If Header is set, then object attributes and properties will be parsed from
+	// a request/response's header.
+	Header *objectV2.Header
+
+	// Method must be represented only as a constant represented in native schema.
+	Method string
+
+	// Role must be representedonly as a constant represented in native schema.
+	Role string
+
+	// An encoded sender's public key string.
+	SenderKey string
+}
+
+var (
+	errMissingOID = fmt.Errorf("object ID is not set")
+)
+
+// CheckAPE checks if a request or a response is permitted creating an ape request and passing
+// it to chain router.
+func (c *checkerImpl) CheckAPE(ctx context.Context, prm Prm) error {
+	r, err := c.newAPERequest(ctx, prm)
+	if err != nil {
+		return fmt.Errorf("failed to create ape request: %w", err)
+	}
+
+	status, ruleFound, err := c.chainRouter.IsAllowed(apechain.Ingress,
+		policyengine.NewRequestTarget(prm.Namespace, prm.Container.EncodeToString()), r)
+	if err != nil {
+		return err
+	}
+
+	if !ruleFound || status == apechain.Allow {
+		return nil
+	}
+
+	return apeErr(prm.Method, status)
+}
+
+const accessDeniedAPEReasonFmt = "access to operation %s is denied by access policy engine: %s"
+
+func apeErr(op string, status apechain.Status) error {
+	errAccessDenied := &apistatus.ObjectAccessDenied{}
+	errAccessDenied.WriteReason(fmt.Sprintf(accessDeniedAPEReasonFmt, op, status.String()))
+	return errAccessDenied
+}
diff --git a/pkg/services/object/ape/checker_test.go b/pkg/services/object/ape/checker_test.go
new file mode 100644
index 000000000..9d548480b
--- /dev/null
+++ b/pkg/services/object/ape/checker_test.go
@@ -0,0 +1,348 @@
+package ape
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"testing"
+
+	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/checksum"
+	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"
+	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
+	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/version"
+	"git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
+	policyengine "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine"
+	"git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine/inmemory"
+	nativeschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/native"
+	"github.com/stretchr/testify/require"
+)
+
+type headerProviderMock struct {
+	m map[oid.Address]*objectSDK.Object
+}
+
+var _ HeaderProvider = (*headerProviderMock)(nil)
+
+func (h *headerProviderMock) addHeader(c cid.ID, o oid.ID, header *objectSDK.Object) {
+	var addr oid.Address
+	addr.SetContainer(c)
+	addr.SetObject(o)
+	h.m[addr] = header
+}
+
+func (h *headerProviderMock) GetHeader(_ context.Context, c cid.ID, o oid.ID) (*objectSDK.Object, error) {
+	var addr oid.Address
+	addr.SetContainer(c)
+	addr.SetObject(o)
+	obj, ok := h.m[addr]
+	if !ok {
+		return nil, fmt.Errorf("address not found")
+	}
+	return obj, nil
+}
+
+func newHeaderProviderMock() *headerProviderMock {
+	return &headerProviderMock{
+		m: make(map[oid.Address]*objectSDK.Object),
+	}
+}
+
+func newContainerIDSDK(t *testing.T, encodedCID string) cid.ID {
+	var cnr cid.ID
+	require.NoError(t, cnr.DecodeString(encodedCID))
+	return cnr
+}
+
+func newObjectIDSDK(t *testing.T, encodedOID *string) *oid.ID {
+	if encodedOID == nil {
+		return nil
+	}
+	obj := new(oid.ID)
+	require.NoError(t, obj.DecodeString(*encodedOID))
+	return obj
+}
+
+type headerObjectSDKParams struct {
+	majorVersion, minorVersion uint32
+	owner                      user.ID
+	epoch                      uint64
+	payloadSize                uint64
+	typ                        objectSDK.Type
+	payloadChecksum            checksum.Checksum
+	payloadHomomorphicHash     checksum.Checksum
+	attributes                 []struct {
+		key string
+		val string
+	}
+}
+
+func stringPtr(s string) *string {
+	return &s
+}
+
+func newHeaderObjectSDK(t *testing.T, cnr cid.ID, oid *oid.ID, headerObjSDK *headerObjectSDKParams) *objectSDK.Object {
+	objSDK := objectSDK.New()
+	objSDK.SetContainerID(cnr)
+	if oid != nil {
+		objSDK.SetID(*oid)
+	}
+	if headerObjSDK == nil {
+		return objSDK
+	}
+	ver := new(version.Version)
+	ver.SetMajor(headerObjSDK.majorVersion)
+	ver.SetMinor(headerObjSDK.minorVersion)
+	objSDK.SetVersion(ver)
+	objSDK.SetCreationEpoch(headerObjSDK.epoch)
+	objSDK.SetOwnerID(headerObjSDK.owner)
+	objSDK.SetPayloadSize(headerObjSDK.payloadSize)
+	objSDK.SetType(headerObjSDK.typ)
+	objSDK.SetPayloadChecksum(headerObjSDK.payloadChecksum)
+	objSDK.SetPayloadHomomorphicHash(headerObjSDK.payloadHomomorphicHash)
+
+	var attrs []objectSDK.Attribute
+	for _, attr := range headerObjSDK.attributes {
+		attrSDK := objectSDK.NewAttribute()
+		attrSDK.SetKey(attr.key)
+		attrSDK.SetValue(attr.val)
+		attrs = append(attrs, *attrSDK)
+	}
+	objSDK.SetAttributes(attrs...)
+
+	return objSDK
+}
+
+type testHeader struct {
+	headerObjSDK *headerObjectSDKParams
+
+	// If fromHeaderProvider is set, then running test should
+	// consider that a header is recieved from a header provider.
+	fromHeaderProvider bool
+
+	// If fromHeaderProvider is set, then running test should
+	// consider that a header is recieved from a message header.
+	fromRequestResponseHeader bool
+}
+
+var (
+	methodsRequiredOID = []string{
+		nativeschema.MethodGetObject,
+		nativeschema.MethodHeadObject,
+		nativeschema.MethodRangeObject,
+		nativeschema.MethodHashObject,
+		nativeschema.MethodDeleteObject,
+	}
+
+	methodsOptionalOID = []string{
+		nativeschema.MethodSearchObject, nativeschema.MethodPutObject,
+	}
+
+	namespace = "test_namespace"
+
+	containerID = "73tQMTYyUkTgmvPR1HWib6pndbhSoBovbnMF7Pws8Rcy"
+
+	objectID = "BzQw5HH3feoxFDD5tCT87Y1726qzgLfxEE7wgtoRzB3R"
+
+	role = "Container"
+
+	senderKey = hex.EncodeToString([]byte{1, 0, 0, 1})
+)
+
+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,
+		},
+		{
+			name:      "oid optional requests are allowed",
+			container: containerID,
+			methods:   methodsOptionalOID,
+		},
+		{
+			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,
+							Object: chain.ObjectResource,
+							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,
+							Object: chain.ObjectRequest,
+							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,
+							Object: chain.ObjectResource,
+							Key:    nativeschema.PropertyKeyObjectPayloadLength,
+							Value:  "1000",
+						},
+					},
+				},
+			},
+			expectAPEErr: true,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			for _, method := range test.methods {
+				t.Run(method, func(t *testing.T) {
+					headerProvider := newHeaderProviderMock()
+
+					cnr := newContainerIDSDK(t, test.container)
+					obj := newObjectIDSDK(t, test.object)
+
+					ls := inmemory.NewInmemoryLocalStorage()
+					ms := inmemory.NewInmemoryMorphRuleChainStorage()
+
+					ls.AddOverride(chain.Ingress, policyengine.ContainerTarget(test.container), &chain.Chain{
+						Rules: test.containerRules,
+					})
+
+					router := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls)
+
+					checker := NewChecker(router, headerProvider)
+
+					prm := Prm{
+						Method:    method,
+						Container: cnr,
+						Object:    obj,
+						Role:      role,
+						SenderKey: senderKey,
+					}
+
+					var headerObjSDK *objectSDK.Object
+					if test.header.headerObjSDK != nil {
+						headerObjSDK = newHeaderObjectSDK(t, 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 {
+						aErr := apeErr(method, chain.AccessDenied)
+						require.ErrorAs(t, err, &aErr)
+					} else {
+						require.NoError(t, err)
+					}
+				})
+			}
+		})
+	}
+}
diff --git a/pkg/services/object/ape/request.go b/pkg/services/object/ape/request.go
new file mode 100644
index 000000000..caf52645c
--- /dev/null
+++ b/pkg/services/object/ape/request.go
@@ -0,0 +1,159 @@
+package ape
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+
+	objectV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object"
+	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/acl"
+	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"
+	aperesource "git.frostfs.info/TrueCloudLab/policy-engine/pkg/resource"
+	nativeschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/native"
+)
+
+type request struct {
+	operation  string
+	resource   *resource
+	properties map[string]string
+}
+
+var _ aperesource.Request = (*request)(nil)
+
+type resource struct {
+	name       string
+	properties map[string]string
+}
+
+var _ aperesource.Resource = (*resource)(nil)
+
+func (r *resource) Name() string {
+	return r.name
+}
+
+func (r *resource) Property(key string) string {
+	return r.properties[key]
+}
+
+func (r *request) Operation() string {
+	return r.operation
+}
+
+func (r *request) Property(key string) string {
+	return r.properties[key]
+}
+
+func (r *request) Resource() aperesource.Resource {
+	return r.resource
+}
+
+func nativeSchemaRole(role acl.Role) string {
+	switch role {
+	case acl.RoleOwner:
+		return nativeschema.PropertyValueContainerRoleOwner
+	case acl.RoleContainer:
+		return nativeschema.PropertyValueContainerRoleContainer
+	case acl.RoleInnerRing:
+		return nativeschema.PropertyValueContainerRoleIR
+	case acl.RoleOthers:
+		return nativeschema.PropertyValueContainerRoleOthers
+	default:
+		return ""
+	}
+}
+
+func resourceName(cid cid.ID, oid *oid.ID, namespace string) string {
+	if namespace == "root" || namespace == "" {
+		if oid != nil {
+			return fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, cid.EncodeToString(), oid.EncodeToString())
+		}
+		return fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, cid.EncodeToString())
+	}
+	if oid != nil {
+		return fmt.Sprintf(nativeschema.ResourceFormatNamespaceContainerObject, namespace, cid.EncodeToString(), oid.EncodeToString())
+	}
+	return fmt.Sprintf(nativeschema.ResourceFormatNamespaceContainerObjects, namespace, cid.EncodeToString())
+}
+
+// objectProperties collects object properties from address parameters and a header if it is passed.
+func objectProperties(cnr cid.ID, oid *oid.ID, header *objectV2.Header) map[string]string {
+	objectProps := map[string]string{
+		nativeschema.PropertyKeyObjectContainerID: cnr.EncodeToString(),
+	}
+
+	if oid != nil {
+		objectProps[nativeschema.PropertyKeyObjectID] = oid.String()
+	}
+
+	if header == nil {
+		return objectProps
+	}
+
+	objV2 := new(objectV2.Object)
+	objV2.SetHeader(header)
+	objSDK := objectSDK.NewFromV2(objV2)
+
+	objectProps[nativeschema.PropertyKeyObjectVersion] = objSDK.Version().String()
+	objectProps[nativeschema.PropertyKeyObjectOwnerID] = objSDK.OwnerID().EncodeToString()
+	objectProps[nativeschema.PropertyKeyObjectCreationEpoch] = strconv.Itoa(int(objSDK.CreationEpoch()))
+	objectProps[nativeschema.PropertyKeyObjectPayloadLength] = strconv.Itoa(int(objSDK.PayloadSize()))
+	objectProps[nativeschema.PropertyKeyObjectType] = objSDK.Type().String()
+
+	pcs, isSet := objSDK.PayloadChecksum()
+	if isSet {
+		objectProps[nativeschema.PropertyKeyObjectPayloadHash] = pcs.String()
+	}
+	hcs, isSet := objSDK.PayloadHomomorphicHash()
+	if isSet {
+		objectProps[nativeschema.PropertyKeyObjectHomomorphicHash] = hcs.String()
+	}
+
+	for _, attr := range header.GetAttributes() {
+		objectProps[attr.GetKey()] = attr.GetValue()
+	}
+
+	return objectProps
+}
+
+// newAPERequest creates an APE request to be passed to a chain router. It collects resource properties from
+// header provided by headerProvider. If it cannot be found in headerProvider, then properties are
+// initialized from header given in prm (if it is set). Otherwise, just CID and OID are set to properties.
+func (c *checkerImpl) newAPERequest(ctx context.Context, prm Prm) (*request, error) {
+	switch prm.Method {
+	case nativeschema.MethodGetObject,
+		nativeschema.MethodHeadObject,
+		nativeschema.MethodRangeObject,
+		nativeschema.MethodHashObject,
+		nativeschema.MethodDeleteObject:
+		if prm.Object == nil {
+			return nil, fmt.Errorf("method %s: %w", prm.Method, errMissingOID)
+		}
+	case nativeschema.MethodSearchObject, nativeschema.MethodPutObject:
+	default:
+		return nil, fmt.Errorf("unknown method: %s", prm.Method)
+	}
+
+	var header *objectV2.Header
+	if prm.Header != nil {
+		header = prm.Header
+	} else if prm.Object != nil {
+		headerObjSDK, err := c.headerProvider.GetHeader(ctx, prm.Container, *prm.Object)
+		if err == nil {
+			header = headerObjSDK.ToV2().GetHeader()
+		}
+	}
+
+	return &request{
+		operation: prm.Method,
+		resource: &resource{
+			name:       resourceName(prm.Container, prm.Object, prm.Namespace),
+			properties: objectProperties(prm.Container, prm.Object, header),
+		},
+		properties: map[string]string{
+			nativeschema.PropertyKeyActorPublicKey: prm.SenderKey,
+			nativeschema.PropertyKeyActorRole:      prm.Role,
+		},
+	}, nil
+}
diff --git a/pkg/services/object/ape/request_test.go b/pkg/services/object/ape/request_test.go
new file mode 100644
index 000000000..55aa691b4
--- /dev/null
+++ b/pkg/services/object/ape/request_test.go
@@ -0,0 +1,324 @@
+package ape
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	objectV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object"
+	checksumtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/checksum/test"
+	objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
+	usertest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user/test"
+	nativeschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/native"
+	"github.com/stretchr/testify/require"
+)
+
+func TestObjectProperties(t *testing.T) {
+	for _, test := range []struct {
+		name      string
+		container string
+		object    *string
+		header    *headerObjectSDKParams
+	}{
+		{
+			name:      "fully filled header",
+			container: containerID,
+			object:    stringPtr(objectID),
+			header: &headerObjectSDKParams{
+				majorVersion:           1,
+				minorVersion:           1,
+				owner:                  usertest.ID(),
+				epoch:                  3,
+				payloadSize:            1000,
+				typ:                    objectSDK.TypeRegular,
+				payloadChecksum:        checksumtest.Checksum(),
+				payloadHomomorphicHash: checksumtest.Checksum(),
+				attributes: []struct {
+					key string
+					val string
+				}{
+					{
+						key: "attr1",
+						val: "val1",
+					},
+					{
+						key: "attr2",
+						val: "val2",
+					},
+				},
+			},
+		},
+		{
+			name:      "partially filled header",
+			container: containerID,
+			header: &headerObjectSDKParams{
+				majorVersion: 1,
+				minorVersion: 1,
+				owner:        usertest.ID(),
+				epoch:        3,
+				attributes: []struct {
+					key string
+					val string
+				}{
+					{
+						key: "attr1",
+						val: "val1",
+					},
+				},
+			},
+		},
+		{
+			name:      "only address paramaters set in header",
+			container: containerID,
+			object:    stringPtr(objectID),
+		},
+		{
+			name:      "only container set in header",
+			container: containerID,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			cnr := newContainerIDSDK(t, test.container)
+			obj := newObjectIDSDK(t, test.object)
+			header := newHeaderObjectSDK(t, cnr, obj, test.header)
+
+			props := objectProperties(cnr, obj, header.ToV2().GetHeader())
+			require.Equal(t, test.container, props[nativeschema.PropertyKeyObjectContainerID])
+
+			if obj != nil {
+				require.Equal(t, *test.object, props[nativeschema.PropertyKeyObjectID])
+			}
+
+			if test.header != nil {
+				require.Equal(t,
+					fmt.Sprintf("v%d.%d", test.header.majorVersion, test.header.minorVersion),
+					props[nativeschema.PropertyKeyObjectVersion],
+				)
+				require.Equal(t, test.header.owner.EncodeToString(), props[nativeschema.PropertyKeyObjectOwnerID])
+				require.Equal(t, fmt.Sprintf("%d", test.header.epoch), props[nativeschema.PropertyKeyObjectCreationEpoch])
+				require.Equal(t, fmt.Sprintf("%d", test.header.payloadSize), props[nativeschema.PropertyKeyObjectPayloadLength])
+				require.Equal(t, test.header.typ.String(), props[nativeschema.PropertyKeyObjectType])
+				require.Equal(t, test.header.payloadChecksum.String(), props[nativeschema.PropertyKeyObjectPayloadHash])
+				require.Equal(t, test.header.payloadHomomorphicHash.String(), props[nativeschema.PropertyKeyObjectHomomorphicHash])
+
+				for _, attr := range test.header.attributes {
+					require.Equal(t, attr.val, props[attr.key])
+				}
+			}
+		})
+	}
+}
+
+func TestNewAPERequest(t *testing.T) {
+	tests := []struct {
+		name      string
+		methods   []string
+		namespace string
+		container string
+		object    *string
+		header    testHeader
+		expectErr error
+	}{
+		{
+			name:      "oid required requests",
+			methods:   methodsRequiredOID,
+			namespace: namespace,
+			container: containerID,
+			object:    stringPtr(objectID),
+			header: testHeader{
+				headerObjSDK: &headerObjectSDKParams{
+					majorVersion:           1,
+					minorVersion:           1,
+					owner:                  usertest.ID(),
+					epoch:                  3,
+					payloadSize:            1000,
+					typ:                    objectSDK.TypeRegular,
+					payloadChecksum:        checksumtest.Checksum(),
+					payloadHomomorphicHash: checksumtest.Checksum(),
+				},
+				fromHeaderProvider: true,
+			},
+		},
+		{
+			name:      "oid required requests but header cannot be found locally",
+			methods:   methodsRequiredOID,
+			namespace: namespace,
+			container: containerID,
+			object:    stringPtr(objectID),
+			header:    testHeader{},
+		},
+		{
+			name:      "oid required requests missed oid",
+			methods:   methodsRequiredOID,
+			namespace: namespace,
+			container: containerID,
+			object:    nil,
+			header:    testHeader{},
+			expectErr: errMissingOID,
+		},
+		{
+			name:      "response for oid required requests",
+			methods:   methodsRequiredOID,
+			namespace: namespace,
+			container: containerID,
+			object:    stringPtr(objectID),
+			header: testHeader{
+				headerObjSDK: &headerObjectSDKParams{
+					majorVersion:           1,
+					minorVersion:           1,
+					owner:                  usertest.ID(),
+					epoch:                  3,
+					payloadSize:            1000,
+					typ:                    objectSDK.TypeRegular,
+					payloadChecksum:        checksumtest.Checksum(),
+					payloadHomomorphicHash: checksumtest.Checksum(),
+				},
+				fromRequestResponseHeader: true,
+			},
+		},
+		{
+			name:      "oid not required methods request",
+			methods:   methodsOptionalOID,
+			namespace: namespace,
+			container: containerID,
+			object:    nil,
+			header: testHeader{
+				headerObjSDK: &headerObjectSDKParams{
+					majorVersion: 6,
+					minorVersion: 66,
+					owner:        usertest.ID(),
+					epoch:        3,
+					typ:          objectSDK.TypeLock,
+				},
+				fromRequestResponseHeader: true,
+			},
+		},
+		{
+			name:      "oid not required methods request but no header",
+			methods:   methodsOptionalOID,
+			namespace: namespace,
+			container: containerID,
+			object:    nil,
+			header:    testHeader{},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			for _, method := range test.methods {
+				t.Run(method, func(t *testing.T) {
+					cnr := newContainerIDSDK(t, test.container)
+					obj := newObjectIDSDK(t, test.object)
+
+					prm := Prm{
+						Namespace: test.namespace,
+						Method:    method,
+						Container: cnr,
+						Object:    obj,
+						Role:      role,
+						SenderKey: senderKey,
+					}
+
+					headerSource := newHeaderProviderMock()
+
+					var headerObjSDK *objectSDK.Object
+					if test.header.headerObjSDK != nil {
+						headerObjSDK = newHeaderObjectSDK(t, 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")
+							headerSource.addHeader(cnr, *obj, headerObjSDK)
+						} else if test.header.fromRequestResponseHeader {
+							prm.Header = headerObjSDK.ToV2().GetHeader()
+						}
+					}
+
+					c := checkerImpl{
+						headerProvider: headerSource,
+					}
+
+					r, err := c.newAPERequest(context.TODO(), prm)
+					if test.expectErr != nil {
+						require.Error(t, err)
+						require.ErrorIs(t, err, test.expectErr)
+						return
+					}
+
+					expectedRequest := request{
+						operation: method,
+						resource: &resource{
+							name: resourceName(cnr, obj, prm.Namespace),
+							properties: objectProperties(cnr, obj, func() *objectV2.Header {
+								if headerObjSDK != nil {
+									return headerObjSDK.ToV2().GetHeader()
+								}
+								return prm.Header
+							}()),
+						},
+						properties: map[string]string{
+							nativeschema.PropertyKeyActorPublicKey: prm.SenderKey,
+							nativeschema.PropertyKeyActorRole:      prm.Role,
+						},
+					}
+
+					require.Equal(t, expectedRequest, *r)
+				})
+			}
+		})
+	}
+}
+
+func TestResourceName(t *testing.T) {
+	for _, test := range []struct {
+		name      string
+		namespace string
+		container string
+		object    *string
+		expected  string
+	}{
+		{
+			name:      "non-root namespace, CID",
+			namespace: namespace,
+			container: containerID,
+			expected:  fmt.Sprintf("native:object/%s/%s/*", namespace, containerID),
+		},
+		{
+			name:      "non-root namespace, CID, OID",
+			namespace: namespace,
+			container: containerID,
+			object:    stringPtr(objectID),
+			expected:  fmt.Sprintf("native:object/%s/%s/%s", namespace, containerID, objectID),
+		},
+		{
+			name:      "empty namespace, CID",
+			namespace: "",
+			container: containerID,
+			expected:  fmt.Sprintf("native:object//%s/*", containerID),
+		},
+		{
+			name:      "empty namespace, CID, OID",
+			namespace: "",
+			container: containerID,
+			object:    stringPtr(objectID),
+			expected:  fmt.Sprintf("native:object//%s/%s", containerID, objectID),
+		},
+		{
+			name:      "root namespace, CID",
+			namespace: "root",
+			container: containerID,
+			expected:  fmt.Sprintf("native:object//%s/*", containerID),
+		},
+		{
+			name:      "root namespace, CID, OID",
+			namespace: "root",
+			container: containerID,
+			object:    stringPtr(objectID),
+			expected:  fmt.Sprintf("native:object//%s/%s", containerID, objectID),
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			cnr := newContainerIDSDK(t, test.container)
+			obj := newObjectIDSDK(t, test.object)
+			require.Equal(t, test.expected, resourceName(cnr, obj, test.namespace))
+		})
+	}
+}
diff --git a/pkg/services/object/ape/service.go b/pkg/services/object/ape/service.go
new file mode 100644
index 000000000..df4dcc306
--- /dev/null
+++ b/pkg/services/object/ape/service.go
@@ -0,0 +1,404 @@
+package ape
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+
+	objectV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object"
+	"git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/refs"
+	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/engine"
+	objectSvc "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object"
+	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util/logger"
+	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"
+	nativeschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/native"
+)
+
+type Service struct {
+	log *logger.Logger
+
+	apeChecker Checker
+
+	next objectSvc.ServiceServer
+}
+
+var _ objectSvc.ServiceServer = (*Service)(nil)
+
+type HeaderProvider interface {
+	GetHeader(ctx context.Context, cnr cid.ID, oid oid.ID) (*objectSDK.Object, error)
+}
+
+type storageEngineHeaderProvider struct {
+	storageEngine *engine.StorageEngine
+}
+
+func (p storageEngineHeaderProvider) GetHeader(ctx context.Context, cnr cid.ID, objID oid.ID) (*objectSDK.Object, error) {
+	var addr oid.Address
+	addr.SetContainer(cnr)
+	addr.SetObject(objID)
+	return engine.Head(ctx, p.storageEngine, addr)
+}
+
+func NewStorageEngineHeaderProvider(e *engine.StorageEngine) HeaderProvider {
+	return storageEngineHeaderProvider{
+		storageEngine: e,
+	}
+}
+
+func NewService(log *logger.Logger, apeChecker Checker, next objectSvc.ServiceServer) *Service {
+	return &Service{
+		log:        log,
+		apeChecker: apeChecker,
+		next:       next,
+	}
+}
+
+type getStreamBasicChecker struct {
+	objectSvc.GetObjectStream
+
+	apeChecker Checker
+
+	senderKey []byte
+
+	role string
+}
+
+func (g *getStreamBasicChecker) Send(resp *objectV2.GetResponse) error {
+	if partInit, ok := resp.GetBody().GetObjectPart().(*objectV2.GetObjectPartInit); ok {
+		cnrID, objID, err := getAddressParamsSDK(partInit.GetHeader().GetContainerID(), partInit.GetObjectID())
+		if err != nil {
+			return err
+		}
+
+		prm := Prm{
+			Container: cnrID,
+			Object:    objID,
+			Header:    partInit.GetHeader(),
+			Method:    nativeschema.MethodGetContainer,
+			SenderKey: hex.EncodeToString(g.senderKey),
+			Role:      g.role,
+		}
+
+		if err := g.apeChecker.CheckAPE(g.Context(), prm); err != nil {
+			return err
+		}
+	}
+	return g.GetObjectStream.Send(resp)
+}
+
+func requestContext(ctx context.Context) (*objectSvc.RequestContext, error) {
+	untyped := ctx.Value(objectSvc.RequestContextKey)
+	if untyped == nil {
+		return nil, fmt.Errorf("no key %s in context", objectSvc.RequestContextKey)
+	}
+	rc, ok := untyped.(*objectSvc.RequestContext)
+	if !ok {
+		return nil, fmt.Errorf("failed cast to RequestContext")
+	}
+	return rc, nil
+}
+
+func (c *Service) Get(request *objectV2.GetRequest, stream objectSvc.GetObjectStream) error {
+	cnrID, objID, err := getAddressParamsSDK(request.GetBody().GetAddress().GetContainerID(), request.GetBody().GetAddress().GetObjectID())
+	if err != nil {
+		return err
+	}
+
+	reqCtx, err := requestContext(stream.Context())
+	if err != nil {
+		return err
+	}
+
+	err = c.apeChecker.CheckAPE(stream.Context(), Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Object:    objID,
+		Method:    nativeschema.MethodGetObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	})
+	if err != nil {
+		return err
+	}
+
+	return c.next.Get(request, &getStreamBasicChecker{
+		GetObjectStream: stream,
+		apeChecker:      c.apeChecker,
+	})
+}
+
+type putStreamBasicChecker struct {
+	apeChecker Checker
+
+	next objectSvc.PutObjectStream
+}
+
+func (p *putStreamBasicChecker) Send(ctx context.Context, request *objectV2.PutRequest) error {
+	if partInit, ok := request.GetBody().GetObjectPart().(*objectV2.PutObjectPartInit); ok {
+		reqCtx, err := requestContext(ctx)
+		if err != nil {
+			return err
+		}
+
+		cnrID, objID, err := getAddressParamsSDK(partInit.GetHeader().GetContainerID(), partInit.GetObjectID())
+		if err != nil {
+			return err
+		}
+
+		prm := Prm{
+			Namespace: reqCtx.Namespace,
+			Container: cnrID,
+			Object:    objID,
+			Header:    partInit.GetHeader(),
+			Method:    nativeschema.MethodPutObject,
+			SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+			Role:      nativeSchemaRole(reqCtx.Role),
+		}
+
+		if err := p.apeChecker.CheckAPE(ctx, prm); err != nil {
+			return err
+		}
+	}
+
+	return p.next.Send(ctx, request)
+}
+
+func (p putStreamBasicChecker) CloseAndRecv(ctx context.Context) (*objectV2.PutResponse, error) {
+	return p.next.CloseAndRecv(ctx)
+}
+
+func (c *Service) Put() (objectSvc.PutObjectStream, error) {
+	streamer, err := c.next.Put()
+
+	return &putStreamBasicChecker{
+		apeChecker: c.apeChecker,
+		next:       streamer,
+	}, err
+}
+
+func (c *Service) Head(ctx context.Context, request *objectV2.HeadRequest) (*objectV2.HeadResponse, error) {
+	cnrID, objID, err := getAddressParamsSDK(request.GetBody().GetAddress().GetContainerID(), request.GetBody().GetAddress().GetObjectID())
+	if err != nil {
+		return nil, err
+	}
+
+	reqCtx, err := requestContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	err = c.apeChecker.CheckAPE(ctx, Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Object:    objID,
+		Method:    nativeschema.MethodHeadObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := c.next.Head(ctx, request)
+	if err != nil {
+		return nil, err
+	}
+
+	header := new(objectV2.Header)
+	switch headerPart := resp.GetBody().GetHeaderPart().(type) {
+	case *objectV2.ShortHeader:
+		cidV2 := new(refs.ContainerID)
+		cnrID.WriteToV2(cidV2)
+		header.SetContainerID(cidV2)
+		header.SetVersion(headerPart.GetVersion())
+		header.SetCreationEpoch(headerPart.GetCreationEpoch())
+		header.SetOwnerID(headerPart.GetOwnerID())
+		header.SetObjectType(headerPart.GetObjectType())
+		header.SetHomomorphicHash(header.GetHomomorphicHash())
+		header.SetPayloadLength(headerPart.GetPayloadLength())
+		header.SetPayloadHash(headerPart.GetPayloadHash())
+	case *objectV2.HeaderWithSignature:
+		header = headerPart.GetHeader()
+	default:
+		return resp, nil
+	}
+
+	err = c.apeChecker.CheckAPE(ctx, Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Object:    objID,
+		Header:    header,
+		Method:    nativeschema.MethodHeadObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	})
+	if err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
+
+func (c *Service) Search(request *objectV2.SearchRequest, stream objectSvc.SearchStream) error {
+	var cnrID cid.ID
+	if cnrV2 := request.GetBody().GetContainerID(); cnrV2 != nil {
+		if err := cnrID.ReadFromV2(*cnrV2); err != nil {
+			return err
+		}
+	}
+
+	reqCtx, err := requestContext(stream.Context())
+	if err != nil {
+		return err
+	}
+
+	err = c.apeChecker.CheckAPE(stream.Context(), Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Method:    nativeschema.MethodSearchObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	})
+	if err != nil {
+		return err
+	}
+
+	return c.next.Search(request, stream)
+}
+
+func (c *Service) Delete(ctx context.Context, request *objectV2.DeleteRequest) (*objectV2.DeleteResponse, error) {
+	cnrID, objID, err := getAddressParamsSDK(request.GetBody().GetAddress().GetContainerID(), request.GetBody().GetAddress().GetObjectID())
+	if err != nil {
+		return nil, err
+	}
+
+	reqCtx, err := requestContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	err = c.apeChecker.CheckAPE(ctx, Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Object:    objID,
+		Method:    nativeschema.MethodDeleteObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := c.next.Delete(ctx, request)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
+func (c *Service) GetRange(request *objectV2.GetRangeRequest, stream objectSvc.GetObjectRangeStream) error {
+	cnrID, objID, err := getAddressParamsSDK(request.GetBody().GetAddress().GetContainerID(), request.GetBody().GetAddress().GetObjectID())
+	if err != nil {
+		return err
+	}
+
+	reqCtx, err := requestContext(stream.Context())
+	if err != nil {
+		return err
+	}
+
+	err = c.apeChecker.CheckAPE(stream.Context(), Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Object:    objID,
+		Method:    nativeschema.MethodRangeObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	})
+	if err != nil {
+		return err
+	}
+
+	return c.next.GetRange(request, stream)
+}
+
+func (c *Service) GetRangeHash(ctx context.Context, request *objectV2.GetRangeHashRequest) (*objectV2.GetRangeHashResponse, error) {
+	cnrID, objID, err := getAddressParamsSDK(request.GetBody().GetAddress().GetContainerID(), request.GetBody().GetAddress().GetObjectID())
+	if err != nil {
+		return nil, err
+	}
+
+	reqCtx, err := requestContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	prm := Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Object:    objID,
+		Method:    nativeschema.MethodHashObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	}
+
+	if err = c.apeChecker.CheckAPE(ctx, prm); err != nil {
+		return nil, err
+	}
+
+	resp, err := c.next.GetRangeHash(ctx, request)
+	if err != nil {
+		return nil, err
+	}
+
+	if err = c.apeChecker.CheckAPE(ctx, prm); err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
+
+func (c *Service) PutSingle(ctx context.Context, request *objectV2.PutSingleRequest) (*objectV2.PutSingleResponse, error) {
+	cnrID, objID, err := getAddressParamsSDK(request.GetBody().GetObject().GetHeader().GetContainerID(), request.GetBody().GetObject().GetObjectID())
+	if err != nil {
+		return nil, err
+	}
+
+	reqCtx, err := requestContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	prm := Prm{
+		Namespace: reqCtx.Namespace,
+		Container: cnrID,
+		Object:    objID,
+		Header:    request.GetBody().GetObject().GetHeader(),
+		Method:    nativeschema.MethodPutObject,
+		Role:      nativeSchemaRole(reqCtx.Role),
+		SenderKey: hex.EncodeToString(reqCtx.SenderKey),
+	}
+
+	if err = c.apeChecker.CheckAPE(ctx, prm); err != nil {
+		return nil, err
+	}
+
+	return c.next.PutSingle(ctx, request)
+}
+
+func getAddressParamsSDK(cidV2 *refs.ContainerID, objV2 *refs.ObjectID) (cnrID cid.ID, objID *oid.ID, err error) {
+	if cidV2 != nil {
+		if err = cnrID.ReadFromV2(*cidV2); err != nil {
+			return
+		}
+	}
+
+	if objV2 != nil {
+		objID = new(oid.ID)
+		if err = objID.ReadFromV2(*objV2); err != nil {
+			return
+		}
+	}
+	return
+}
diff --git a/pkg/services/object/ape/types.go b/pkg/services/object/ape/types.go
new file mode 100644
index 000000000..46e55360d
--- /dev/null
+++ b/pkg/services/object/ape/types.go
@@ -0,0 +1,9 @@
+package ape
+
+import "context"
+
+// Checker provides methods to check requests and responses
+// with access policy engine.
+type Checker interface {
+	CheckAPE(context.Context, Prm) error
+}
diff --git a/pkg/services/object/request_context.go b/pkg/services/object/request_context.go
new file mode 100644
index 000000000..4b9aa04d1
--- /dev/null
+++ b/pkg/services/object/request_context.go
@@ -0,0 +1,16 @@
+package object
+
+import "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/acl"
+
+type RequestContextKeyT struct{}
+
+var RequestContextKey = RequestContextKeyT{}
+
+// RequestContext is a context passed between middleware handlers.
+type RequestContext struct {
+	Namespace string
+
+	SenderKey []byte
+
+	Role acl.Role
+}