Skip to content

Commit 8171306

Browse files
Merge pull request #8418 from shirady/get-object-attributes-api
Add S3 GetObjectAttributes API Implementation
2 parents 4379cbf + b647385 commit 8171306

File tree

14 files changed

+630
-9
lines changed

14 files changed

+630
-9
lines changed

src/endpoint/s3/ops/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ exports.get_bucket_versions = require('./s3_get_bucket_versions');
3939
exports.get_bucket_website = require('./s3_get_bucket_website');
4040
exports.get_object = require('./s3_get_object');
4141
exports.get_object_acl = require('./s3_get_object_acl');
42+
exports.get_object_attributes = require('./s3_get_object_attributes');
4243
exports.get_object_legal_hold = require('./s3_get_object_legal_hold');
4344
exports.get_object_retention = require('./s3_get_object_retention');
4445
exports.get_object_tagging = require('./s3_get_object_tagging');
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/* Copyright (C) 2016 NooBaa */
2+
'use strict';
3+
4+
const dbg = require('../../../util/debug_module')(__filename);
5+
const s3_utils = require('../s3_utils');
6+
const http_utils = require('../../../util/http_utils');
7+
const S3Error = require('../s3_errors').S3Error;
8+
9+
/**
10+
* http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGETacl.html
11+
*/
12+
async function get_object_attributes(req, res) {
13+
const version_id = s3_utils.parse_version_id(req.query.versionId);
14+
const encryption = s3_utils.parse_encryption(req);
15+
const attributes = _parse_attributes(req);
16+
17+
const params = {
18+
bucket: req.params.bucket,
19+
key: req.params.key,
20+
version_id: version_id,
21+
encryption: encryption, // GAP - we don't use it currently
22+
md_conditions: http_utils.get_md_conditions(req), // GAP - we don't use it currently in all namespaces (for example - not in NSFS)
23+
attributes: attributes,
24+
};
25+
dbg.log2('params after parsing', params);
26+
const reply = await req.object_sdk.get_object_attributes(params);
27+
s3_utils.set_response_headers_get_object_attributes(req, res, reply, version_id);
28+
return _make_reply_according_to_attributes(reply, attributes);
29+
}
30+
31+
/**
32+
* _parse_attributes parses the header in which the attributes are passed as tring with ',' as separator
33+
* and returns array with the the attributes according to the valid attributes list (otherwise it throws an error)
34+
* @param {nb.S3Request} req
35+
* @returns {string[]}
36+
*/
37+
function _parse_attributes(req) {
38+
const attributes_str = req.headers['x-amz-object-attributes'];
39+
if (!attributes_str) {
40+
dbg.error('get_object_attributes: must pass at least one attribute from:',
41+
s3_utils.OBJECT_ATTRIBUTES);
42+
throw new S3Error(S3Error.InvalidArgument);
43+
}
44+
const attributes = attributes_str.split(',').map(item => item.trim());
45+
const all_valid = attributes.every(item => s3_utils.OBJECT_ATTRIBUTES.includes(item));
46+
if (!all_valid) {
47+
dbg.error('get_object_attributes: received attributes:', attributes,
48+
'at least one of the attributes is not from:', s3_utils.OBJECT_ATTRIBUTES);
49+
throw new S3Error(S3Error.InvalidArgument);
50+
}
51+
return attributes;
52+
}
53+
54+
/**
55+
* _make_reply_according_to_attributes currently the reply is md_object in most of the namespaces
56+
* and we return the properties according to the attributes the client asked for
57+
* @param {object} reply
58+
* @param {object} attributes
59+
* @returns {object}
60+
*/
61+
function _make_reply_according_to_attributes(reply, attributes) {
62+
const reply_without_filter = {
63+
ETag: `"${reply.etag}"`,
64+
Checksum: reply.checksum,
65+
ObjectParts: reply.object_parts,
66+
StorageClass: reply.storage_class,
67+
ObjectSize: reply.size
68+
};
69+
const filtered_reply = {
70+
GetObjectAttributesOutput: {
71+
}
72+
};
73+
for (const key of attributes) {
74+
if (reply_without_filter[key] === undefined) {
75+
dbg.warn('Requested for attributes', attributes,
76+
'but currently NooBaa does not support these attributes:',
77+
s3_utils.OBJECT_ATTRIBUTES_UNSUPPORTED, '(expect namespace s3)');
78+
} else {
79+
filtered_reply.GetObjectAttributesOutput[key] = reply_without_filter[key];
80+
}
81+
}
82+
return filtered_reply;
83+
}
84+
85+
module.exports = {
86+
handler: get_object_attributes,
87+
body: {
88+
type: 'empty',
89+
},
90+
reply: {
91+
type: 'xml',
92+
},
93+
};

src/endpoint/s3/s3_bucket_policy_utils.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const OP_NAME_TO_ACTION = Object.freeze({
4545
get_bucket_object_lock: { regular: "s3:GetBucketObjectLockConfiguration" },
4646
get_bucket: { regular: "s3:ListBucket" },
4747
get_object_acl: { regular: "s3:GetObjectAcl" },
48+
get_object_attributes: { regular: ["s3:GetObject", "s3:GetObjectAttributes"], versioned: ["s3:GetObjectVersion", "s3:GetObjectVersionAttributes"] }, // Notice - special case
4849
get_object_tagging: { regular: "s3:GetObjectTagging", versioned: "s3:GetObjectVersionTagging" },
4950
get_object_uploadId: { regular: "s3:ListMultipartUploadParts" },
5051
get_object_retention: { regular: "s3:GetObjectRetention"},
@@ -139,11 +140,16 @@ async function _is_object_tag_fit(req, predicate, value) {
139140
async function has_bucket_policy_permission(policy, account, method, arn_path, req) {
140141
const [allow_statements, deny_statements] = _.partition(policy.Statement, statement => statement.Effect === 'Allow');
141142

143+
// the case where the permission is an array started in op get_object_attributes
144+
const method_arr = Array.isArray(method) ? method : [method];
145+
142146
// look for explicit denies
143-
if (await _is_statements_fit(deny_statements, account, method, arn_path, req)) return 'DENY';
147+
const res_arr_deny = await is_statement_fit_of_method_array(deny_statements, account, method_arr, arn_path, req);
148+
if (res_arr_deny.every(item => item)) return 'DENY';
144149

145150
// look for explicit allows
146-
if (await _is_statements_fit(allow_statements, account, method, arn_path, req)) return 'ALLOW';
151+
const res_arr_allow = await is_statement_fit_of_method_array(allow_statements, account, method_arr, arn_path, req);
152+
if (res_arr_allow.every(item => item)) return 'ALLOW';
147153

148154
// implicit deny
149155
return 'IMPLICIT_DENY';
@@ -156,6 +162,7 @@ function _is_action_fit(method, statement) {
156162
dbg.log1('bucket_policy: ', statement.Action ? 'Action' : 'NotAction', ' fit?', action, method);
157163
if ((action === '*') || (action === 's3:*') || (action === method)) {
158164
action_fit = true;
165+
break;
159166
}
160167
}
161168
return statement.Action ? action_fit : !action_fit;
@@ -170,6 +177,7 @@ function _is_principal_fit(account, statement) {
170177
dbg.log1('bucket_policy: ', statement.Principal ? 'Principal' : 'NotPrincipal', ' fit?', principal, account);
171178
if ((principal.unwrap() === '*') || (principal.unwrap() === account)) {
172179
principal_fit = true;
180+
break;
173181
}
174182
}
175183
return statement.Principal ? principal_fit : !principal_fit;
@@ -184,11 +192,17 @@ function _is_resource_fit(arn_path, statement) {
184192
dbg.log1('bucket_policy: ', statement.Resource ? 'Resource' : 'NotResource', ' fit?', resource_regex, arn_path);
185193
if (resource_regex.test(arn_path)) {
186194
resource_fit = true;
195+
break;
187196
}
188197
}
189198
return statement.Resource ? resource_fit : !resource_fit;
190199
}
191200

201+
async function is_statement_fit_of_method_array(statements, account, method_arr, arn_path, req) {
202+
return Promise.all(method_arr.map(method_permission =>
203+
_is_statements_fit(statements, account, method_permission, arn_path, req)));
204+
}
205+
192206
async function _is_statements_fit(statements, account, method, arn_path, req) {
193207
for (const statement of statements) {
194208
const action_fit = _is_action_fit(method, statement);
@@ -237,7 +251,7 @@ function _parse_condition_keys(condition_statement) {
237251
}
238252

239253
async function validate_s3_policy(policy, bucket_name, get_account_handler) {
240-
const all_op_names = _.compact(_.flatMap(OP_NAME_TO_ACTION, action => [action.regular, action.versioned]));
254+
const all_op_names = _.flatten(_.compact(_.flatMap(OP_NAME_TO_ACTION, action => [action.regular, action.versioned])));
241255
for (const statement of policy.Statement) {
242256

243257
const statement_principal = statement.Principal || statement.NotPrincipal;

src/endpoint/s3/s3_rest.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const OBJECT_SUB_RESOURCES = Object.freeze({
5757
'legal-hold': 'legal_hold',
5858
'retention': 'retention',
5959
'select': 'select',
60+
'attributes': 'attributes',
6061
});
6162

6263
let usage_report = new_usage_report();
@@ -296,6 +297,11 @@ async function authorize_anonymous_access(s3_policy, method, arn_path, req) {
296297
throw new S3Error(S3Error.AccessDenied);
297298
}
298299

300+
/**
301+
* _get_method_from_req parses the permission needed according to the bucket policy
302+
* @param {nb.S3Request} req
303+
* @returns {string|string[]}
304+
*/
299305
function _get_method_from_req(req) {
300306
const s3_op = s3_bucket_policy_utils.OP_NAME_TO_ACTION[req.op_name];
301307
if (!s3_op) {

src/endpoint/s3/s3_utils.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const base64_regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{
3838

3939
const X_NOOBAA_AVAILABLE_STORAGE_CLASSES = 'x-noobaa-available-storage-classes';
4040

41+
const OBJECT_ATTRIBUTES = Object.freeze(['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize']);
42+
const OBJECT_ATTRIBUTES_UNSUPPORTED = Object.freeze(['Checksum', 'ObjectParts']);
43+
4144
/**
4245
* get_default_object_owner returns bucket_owner info if exists
4346
* else it'll return the default owner
@@ -324,6 +327,28 @@ function set_response_object_md(res, object_md) {
324327
}
325328
}
326329

330+
/** set_response_headers_get_object_attributes is based on set_response_object_md
331+
* and serves get_object_attributes
332+
* @param {nb.S3Request} req
333+
* @param {nb.S3Response} res
334+
* @param {object} reply
335+
* @param {string} version_id
336+
*/
337+
function set_response_headers_get_object_attributes(req, res, reply, version_id) {
338+
if (version_id) {
339+
res.setHeader('x-amz-version-id', version_id);
340+
if (reply.delete_marker) {
341+
res.setHeader('x-amz-delete-marker', 'true');
342+
}
343+
}
344+
if (reply.last_modified_time) {
345+
res.setHeader('Last-Modified', time_utils.format_http_header_date(new Date(reply.last_modified_time)));
346+
} else {
347+
res.setHeader('Last-Modified', time_utils.format_http_header_date(new Date(reply.create_time)));
348+
}
349+
set_encryption_response_headers(req, res, reply.encryption);
350+
}
351+
327352
/**
328353
* @param {nb.S3Response} res
329354
* @param {Array<string>} [supported_storage_classes]
@@ -774,6 +799,7 @@ exports.parse_part_number = parse_part_number;
774799
exports.parse_copy_source = parse_copy_source;
775800
exports.format_copy_source = format_copy_source;
776801
exports.set_response_object_md = set_response_object_md;
802+
exports.set_response_headers_get_object_attributes = set_response_headers_get_object_attributes;
777803
exports.parse_storage_class = parse_storage_class;
778804
exports.parse_storage_class_header = parse_storage_class_header;
779805
exports.parse_encryption = parse_encryption;
@@ -801,3 +827,6 @@ exports.get_default_object_owner = get_default_object_owner;
801827
exports.set_response_supported_storage_classes = set_response_supported_storage_classes;
802828
exports.cont_tok_to_key_marker = cont_tok_to_key_marker;
803829
exports.key_marker_to_cont_tok = key_marker_to_cont_tok;
830+
exports.parse_sse_c = parse_sse_c;
831+
exports.OBJECT_ATTRIBUTES = OBJECT_ATTRIBUTES;
832+
exports.OBJECT_ATTRIBUTES_UNSUPPORTED = OBJECT_ATTRIBUTES_UNSUPPORTED;

src/sdk/namespace_fs.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2390,7 +2390,8 @@ class NamespaceFS {
23902390
const etag = this._get_etag(stat);
23912391
const create_time = stat.mtime.getTime();
23922392
const encryption = this._get_encryption_info(stat);
2393-
const version_id = (this._is_versioning_enabled() || this._is_versioning_suspended()) && this._get_version_id_by_xattr(stat);
2393+
const version_id = ((this._is_versioning_enabled() || this._is_versioning_suspended()) && this._get_version_id_by_xattr(stat)) ||
2394+
undefined;
23942395
const delete_marker = stat.xattr?.[XATTR_DELETE_MARKER] === 'true';
23952396
const dir_content_type = stat.xattr?.[XATTR_DIR_CONTENT] && ((Number(stat.xattr?.[XATTR_DIR_CONTENT]) > 0 && 'application/octet-stream') || 'application/x-directory');
23962397
const content_type = stat.xattr?.[XATTR_CONTENT_TYPE] ||

src/sdk/namespace_s3.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,43 @@ class NamespaceS3 {
745745
throw new S3Error(S3Error.NotImplemented);
746746
}
747747

748+
//////////////////////////
749+
// OBJECT ATTRIBUTES //
750+
//////////////////////////
751+
752+
async get_object_attributes(params, object_sdk) {
753+
dbg.log0('NamespaceS3.get_object_attributes:', this.bucket, inspect(params));
754+
await this._prepare_sts_client();
755+
756+
/** @type {AWS.S3.GetObjectAttributesRequest} */
757+
const request = {
758+
Bucket: this.bucket,
759+
Key: params.key,
760+
VersionId: params.version_id,
761+
ObjectAttributes: params.attributes,
762+
};
763+
this._set_md_conditions(params, request);
764+
this._assign_encryption_to_request(params, request);
765+
try {
766+
const res = await this.s3.getObjectAttributes(request).promise();
767+
dbg.log0('NamespaceS3.get_object_attributes:', this.bucket, inspect(params), 'res', inspect(res));
768+
return this._get_s3_object_info(res, params.bucket);
769+
} catch (err) {
770+
this._translate_error_code(params, err);
771+
dbg.warn('NamespaceS3.get_object_attributes:', inspect(err));
772+
// It's totally expected to issue `HeadObject` against an object that doesn't exist
773+
// this shouldn't be counted as an issue for the namespace store
774+
if (err.rpc_code !== 'NO_SUCH_OBJECT') {
775+
object_sdk.rpc_client.pool.update_issues_report({
776+
namespace_resource_id: this.namespace_resource_id,
777+
error_code: String(err.code),
778+
time: Date.now(),
779+
});
780+
}
781+
throw err;
782+
}
783+
}
784+
748785
///////////////
749786
// INTERNALS //
750787
///////////////
@@ -756,7 +793,8 @@ class NamespaceS3 {
756793
* AWS.S3.ObjectVersion &
757794
* AWS.S3.DeleteMarkerEntry &
758795
* AWS.S3.MultipartUpload &
759-
* AWS.S3.GetObjectOutput
796+
* AWS.S3.GetObjectOutput &
797+
* AWS.S3.GetObjectAttributesOutput
760798
* >, 'ChecksumAlgorithm'>} res
761799
* @param {string} bucket
762800
* @param {number} [part_number]
@@ -796,7 +834,10 @@ class NamespaceS3 {
796834
sha256_b64: undefined,
797835
stats: undefined,
798836
tagging: undefined,
799-
object_owner: this._get_object_owner()
837+
object_owner: this._get_object_owner(),
838+
checksum: res.Checksum,
839+
// @ts-ignore // See note in GetObjectAttributesParts in file nb.d.ts
840+
object_parts: res.ObjectParts,
800841
};
801842
}
802843

src/sdk/nb.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as mongodb from 'mongodb';
55
import { EventEmitter } from 'events';
66
import { Readable, Writable } from 'stream';
77
import { IncomingMessage, ServerResponse } from 'http';
8+
import { ObjectPart, Checksum} from '@aws-sdk/client-s3';
89

910
type Semaphore = import('../util/semaphore');
1011
type KeysSemaphore = import('../util/keys_semaphore');
@@ -439,6 +440,8 @@ interface ObjectInfo {
439440
ns?: Namespace;
440441
storage_class?: StorageClass;
441442
restore_status?: { ongoing?: boolean; expiry_time?: Date; };
443+
checksum?: Checksum;
444+
object_parts?: GetObjectAttributesParts;
442445
}
443446

444447

@@ -814,6 +817,7 @@ interface Namespace {
814817
get_blob_block_lists(params: object, object_sdk: ObjectSDK): Promise<any>;
815818

816819
restore_object(params: object, object_sdk: ObjectSDK): Promise<any>;
820+
get_object_attributes(params: object, object_sdk: ObjectSDK): Promise<any>;
817821
}
818822

819823
interface BucketSpace {
@@ -1129,3 +1133,20 @@ interface RestoreStatus {
11291133
ongoing?: boolean;
11301134
expiry_time?: Date;
11311135
}
1136+
1137+
/**********************************************************
1138+
*
1139+
* OTHER - S3 Structure
1140+
*
1141+
**********************************************************/
1142+
1143+
// Since the interface is a bit different between the SDKs
1144+
// we couldn't import and reuse
1145+
interface GetObjectAttributesParts {
1146+
TotalPartsCount?: number;
1147+
PartNumberMarker?: string; // in AWS SDK V2 it is number
1148+
NextPartNumberMarker?: string; // in AWS SDK V2 it is number
1149+
MaxParts?: number;
1150+
IsTruncated?: boolean;
1151+
Parts?: ObjectPart[];
1152+
}

src/sdk/object_sdk.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,24 @@ class ObjectSDK {
11271127
this._check_is_readonly_namespace(ns);
11281128
return ns.put_object_acl(params, this);
11291129
}
1130+
1131+
//////////////////////////
1132+
// OBJECT ATTRIBUTES //
1133+
//////////////////////////
1134+
1135+
async get_object_attributes(params) {
1136+
const ns = await this._get_bucket_namespace(params.bucket);
1137+
if (ns.get_object_attributes) {
1138+
return ns.get_object_attributes(params, this);
1139+
} else {
1140+
// fallback to calling get_object_md without attributes params
1141+
dbg.warn('namespace does not implement get_object_attributes action, fallback to read_object_md');
1142+
const md_params = { ...params };
1143+
delete md_params.attributes; // not part of the schema of read_object_md
1144+
return ns.read_object_md(md_params, this);
1145+
}
1146+
}
1147+
11301148
}
11311149

11321150
// EXPORT

0 commit comments

Comments
 (0)