diff --git a/src/sub-account/README.md b/src/sub-account/README.md index 05edb29..9541ee8 100644 --- a/src/sub-account/README.md +++ b/src/sub-account/README.md @@ -136,23 +136,26 @@ Then they are navigated to that bucket's detail page ### Creating a bucket -#### Scenario: Create Bucket button is visible for users with write permissions - Given the user has write permissions - When the user is on the Buckets page - Then the Create Bucket button is visible +#### Scenario: Create Bucket button is visible to all users + Given the user is on the Buckets page + When the user views the page + Then the Create Bucket button is visible, regardless of the user's permissions -#### Scenario: Create Bucket button is not visible for users without write permissions - Given the user does not have write permissions - When the user is on the Buckets page - Then the Create Bucket button is not visible +#### Scenario: Create bucket fails for users without full or full access limited permissions + Given the user does not have full or full access limited permissions + When the user attempts to create a bucket + Then the action fails + And an error message is shown + And no bucket is created #### Scenario: Open Create Bucket dialog - Given the user has write permissions + Given the user is on the Buckets page When the user clicks Create Bucket Then a dialog is shown with Bucket Name, Select Region, Bucket Versioning, Bucket Logging and Object Locking fields #### Scenario: Successfully create a bucket - Given the Create Bucket dialog is open + Given the user has full or full access limited permissions to all the buckets + And the Create Bucket dialog is open When the user fills in a valid name and region and clicks Create Then the dialog is closed And the bucket is created and appears in the list @@ -206,21 +209,23 @@ ### Deleting a bucket -#### Scenario: Context menu is visible for users with write permissions - Given the user has write permissions - When the user is on the Buckets page - Then a context menu icon is visible on each bucket row - -#### Scenario: Context menu is not visible for users without write permissions - Given the user does not have write permissions - When the user is on the Buckets page - Then the context menu icon is not visible +#### Scenario: Context menu is visible to all users + Given the user is on the Buckets page + When the user views a bucket row + Then a context menu icon is visible, regardless of the user's permissions #### Scenario: Context menu shows View and Delete options - Given the user has write permissions + Given the user is on the Buckets page When the user clicks the context menu icon on a bucket row Then a menu is shown with View and Delete options +#### Scenario: Delete from context menu fails for users without full permissions + Given the user does not have full access + When the user attempts to delete a bucket from the context menu + Then the action fails + And an error message is shown + And the bucket is not deleted + #### Scenario: Delete from context menu shows confirmation dialog Given the context menu is open When the user clicks Delete @@ -237,7 +242,8 @@ Then the Delete button is disabled #### Scenario: Confirm bucket deletion - Given the confirmation dialog is shown + Given the user has full access to the bucket + And the confirmation dialog is shown When the user types the bucket name correctly and clicks Delete Then the bucket is deleted And the user is redirected to the Buckets page @@ -342,10 +348,18 @@ Then a confirmation dialog is shown #### Scenario: Confirm deletion of selected objects - Given the confirmation dialog is shown + Given the user has write permissions on the bucket + And the confirmation dialog is shown When the user confirms deletion Then the selected objects are removed from the list +#### Scenario: Delete selected objects fails for users without write permissions + Given the user does not have write permissions on the bucket + When the user attempts to delete the selected objects + Then the action fails + And an error message is shown + And the objects remain in the list + #### Scenario: Cancel deletion of selected objects Given the confirmation dialog is shown When the user cancels @@ -353,35 +367,40 @@ ### Upload Files -#### Scenario: Upload Files button is visible for users with write permissions - Given the user has write permissions on the bucket - When the user is on the bucket detail page - Then the Upload Files button is visible +#### Scenario: Upload Files button is visible to all users + Given the user is on the bucket detail page + When the user views the page + Then the Upload Files button is visible, regardless of the user's permissions -#### Scenario: Upload Files button is not visible for users without write permissions +#### Scenario: Upload files fails for users without write permissions Given the user does not have write permissions on the bucket - When the user is on the bucket detail page - Then the Upload Files button is not visible + When the user attempts to upload files + Then the action fails + And an error message is shown + And no files are uploaded #### Scenario: Open Upload Files dialog - Given the user has write permissions on the bucket + Given the user is on the bucket detail page When the user clicks Upload Files Then a dialog is shown with a drag and drop area and a Browse Files button #### Scenario: Upload files via Browse Files - Given the Upload Files dialog is open + Given the user has write permissions on the bucket + And the Upload Files dialog is open When the user clicks Browse Files and selects one or more files Then the files are uploaded to the bucket And they appear in the object list #### Scenario: Upload files via drag and drop - Given the Upload Files dialog is open + Given the user has write permissions on the bucket + And the Upload Files dialog is open When the user drags and drops one or more files into the drop area Then the files are uploaded to the bucket And they appear in the object list #### Scenario: Upload a folder via drag and drop - Given the Upload Files dialog is open + Given the user has write permissions on the bucket + And the Upload Files dialog is open When the user drags and drops a folder into the drop area Then all files within that folder (including subfolders) are uploaded And their keys preserve the folder's structure as a prefix @@ -401,23 +420,26 @@ ### Create Folder -#### Scenario: Create Folder button is visible for users with write permissions - Given the user has write permissions on the bucket - When the user is on the bucket detail page - Then the Create Folder button is visible +#### Scenario: Create Folder button is visible to all users + Given the user is on the bucket detail page + When the user views the page + Then the Create Folder button is visible, regardless of the user's permissions -#### Scenario: Create Folder button is not visible for users without write permissions +#### Scenario: Create folder fails for users without write permissions Given the user does not have write permissions on the bucket - When the user is on the bucket detail page - Then the Create Folder button is not visible + When the user attempts to create a folder + Then the action fails + And an error message is shown + And no folder is created #### Scenario: Open Create Folder dialog - Given the user has write permissions on the bucket + Given the user is on the bucket detail page When the user clicks Create Folder Then a dialog is shown with a Folder Name field and Cancel and Create buttons #### Scenario: Successfully create a folder - Given the Create Folder dialog is open + Given the user has write permissions on the bucket + And the Create Folder dialog is open When the user enters a valid folder name and clicks Create Then the folder is created and appears in the object list @@ -473,7 +495,8 @@ Then a confirmation dialog is shown #### Scenario: Confirm enable versioning - Given the Enable Versioning confirmation dialog is shown + Given the user has full or full access limited permissions + And the Enable Versioning confirmation dialog is shown When the user clicks Confirm Then versioning is enabled for the bucket And the Bucket Versioning section shows Enabled as the current state @@ -489,7 +512,8 @@ Then a confirmation dialog is shown #### Scenario: Confirm suspend versioning - Given the Suspend Versioning confirmation dialog is shown + Given the user has full or full access limited permissions + And the Suspend Versioning confirmation dialog is shown When the user clicks Confirm Then versioning is suspended for the bucket And the Bucket Versioning section shows Suspended as the current state @@ -499,6 +523,18 @@ When the user clicks Cancel or the X button Then the dialog is closed and versioning remains unchanged +#### Scenario: Versioning controls are visible to all users + Given the user is on the Properties tab + When the user views the Bucket Versioning section + Then the current versioning state and its controls are visible, regardless of the user's permissions + +#### Scenario: Change versioning fails for users without full or full access limited permissions + Given the user does not have full or full access limited permissions + When the user attempts to enable or suspend versioning + Then the action fails + And an error message is shown + And the versioning state remains unchanged + ### Bucket Logging #### Scenario: Enable Bucket Logging shows additional fields @@ -512,17 +548,31 @@ Then a list of available buckets is shown #### Scenario: Successfully update bucket logging - Given the Enable Bucket Logging toggle is enabled + Given the user has full or full access limited permissions + And the Enable Bucket Logging toggle is enabled And the user has selected a bucket to store logs When the user clicks Update Then the logging configuration is saved #### Scenario: Disable Bucket Logging - Given the Enable Bucket Logging toggle is enabled + Given the user has full or full access limited permissions + And the Enable Bucket Logging toggle is enabled When the user disables the toggle And clicks Update Then logging is disabled for the bucket +#### Scenario: Logging controls are visible to all users + Given the user is on the Properties tab + When the user views the Bucket Logging section + Then the current logging configuration and its controls are visible, regardless of the user's permissions + +#### Scenario: Update bucket logging fails for users without full or full access limited permissions + Given the user does not have full or full access limited permissions + When the user attempts to update the logging configuration + Then the action fails + And an error message is shown + And the logging configuration remains unchanged + ### Object Locking #### Scenario: Object Locking section shows informational message when not enabled at creation @@ -562,25 +612,40 @@ Then the Update button is disabled #### Scenario: Successfully configure bucket-level object retention - Given a retention mode is selected + Given the user has full or full access limited permissions + And a retention mode is selected And the user has filled in Time Scale and Retention Time When the user clicks Update Then the retention configuration is saved +#### Scenario: Object Locking controls are visible to all users + Given the bucket was created with Object Locking enabled + When the user is on the Properties tab + Then the current object retention configuration and its controls are visible, regardless of the user's permissions + +#### Scenario: Configure object retention fails for users without full or full access limited permissions + Given the user does not have full or full access limited permissions + When the user attempts to update the object retention configuration + Then the action fails + And an error message is shown + And the retention configuration remains unchanged + ### Delete Bucket -#### Scenario: Delete button is visible for users with write permissions - Given the user has write permissions - When the user is on the Properties tab - Then the Delete button is visible +#### Scenario: Delete button is visible to all users + Given the user is on the Properties tab + When the user views the page + Then the Delete button is visible, regardless of the user's permissions -#### Scenario: Delete button is not visible for users without write permissions - Given the user does not have write permissions - When the user is on the Properties tab - Then the Delete button is not visible +#### Scenario: Delete bucket fails for users without full permissions + Given the user does not have full access + When the user attempts to delete the bucket from the Properties tab + Then the action fails + And an error message is shown + And the bucket is not deleted #### Scenario: Delete bucket from Properties shows confirmation dialog - Given the user has write permissions + Given the user is on the Properties tab When the user clicks Delete on the Properties tab Then a confirmation dialog is shown requiring the user to type the bucket name to proceed @@ -791,16 +856,24 @@ redirects to login. ### Members tab — assigning permissions -- When the admin clicks Assign Permissions on a member and fills in a bucket name and permission level and submits, +- When the admin clicks Assign Permissions on a member and fills in a bucket (a single bucket or all buckets) and access level and submits, then the permissions are saved and the modal closes. -- When the bucket name is empty, - then the submit button is disabled. -- When prefixes are left empty, - then access is granted to the entire bucket. +- Each rule grants a whole bucket; there are four access levels: + - Read — view the bucket and its contents (read objects and versions, list, and read bucket configuration: versioning, logging, object locking, ACL and retention). + - Write — Read, plus saving and deleting objects. + - Full limited — Write, plus changing the bucket configuration (versioning, logging, object locking), but it cannot delete the bucket. + - Full — Full limited, plus deleting the bucket. +- When the admin clicks Advanced, + then they can edit the raw IAM policy JSON directly instead of using the builder. +- When the raw JSON is invalid, + then an error is shown and the policy cannot be saved. - When the request is in progress, then the submit button shows a loading indicator and is disabled. - When the request fails, then an error message is shown. +- When the admin saves permissions, closes the modal, and opens it again, the modal should load the user's current permissions. +- When the admin clicks Use builder on a policy the builder cannot represent (custom rules the builder does not model, e.g. object prefixes or unknown actions), + then a confirmation warns that switching resets everything to an empty builder. ### Access Keys tab - When the user opens the Access Keys tab, diff --git a/src/sub-account/components/permissions-manager/AssignPermissionsModal.tsx b/src/sub-account/components/permissions-manager/AssignPermissionsModal.tsx index b63969c..8ca8c67 100644 --- a/src/sub-account/components/permissions-manager/AssignPermissionsModal.tsx +++ b/src/sub-account/components/permissions-manager/AssignPermissionsModal.tsx @@ -10,7 +10,7 @@ import { useSubAccountS3Client } from '../../hooks/useSubAccountS3Client'; import { usePolicyEditor } from '../../hooks/usePolicyEditor'; import { BucketRulesBuilder } from './BucketRulesBuilder'; import { PolicyJsonEditor } from './PolicyJsonEditor'; -import { PolicyDocument, parseStatements, rulesToPolicy } from '../../services/iamPolicy.service'; +import { PolicyDocument, PolicyToBucketRules, BucketRulesToPolicy } from '../../services/iamPolicy.service'; interface AssignPermissionsModalProps { isOpen: boolean; @@ -41,18 +41,20 @@ export const AssignPermissionsModal = ({ isOpen, isLoading, memberEmail, onClose const { entityId, memberId } = useSubAccount(); const { client } = useSubAccountS3Client(isOpen ? entityId : null, isOpen ? memberId : null); - const { editor, isFetching, fetchError, patchEditor, enterAdvanced, tryExitAdvanced, resetToBuilder } = + const { editor, isFetching, fetchError, patchEditor, enterAdvanced, switchToBuilder, resetToBuilder } = usePolicyEditor({ isOpen, onFetchPermissions }); const { rules, isAdvanced, jsonText } = editor; const [confirmBuilderOpen, setConfirmBuilderOpen] = useState(false); - const parsedStatements = useMemo(() => (isAdvanced ? parseStatements(jsonText) : null), [isAdvanced, jsonText]); + const parsedStatements = useMemo(() => (isAdvanced ? PolicyToBucketRules.parseJson(jsonText) : null), [isAdvanced, jsonText]); const jsonError = isAdvanced && jsonText.trim().length > 0 && parsedStatements === null; const handleUseBuilder = () => { - // Custom JSON can't round-trip, so exiting Advanced would reset the builder - if (!tryExitAdvanced().exited) { + // Custom JSON can't round-trip, so switching would reset the builder: confirm first. + if (parsedStatements && !PolicyToBucketRules.isCustom(parsedStatements)) { + switchToBuilder(parsedStatements); + } else { setConfirmBuilderOpen(true); } }; @@ -65,11 +67,13 @@ export const AssignPermissionsModal = ({ isOpen, isLoading, memberEmail, onClose const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (isAdvanced) { - if (!parsedStatements) return; + if (!parsedStatements) { + return; + } await onAssign({ Version: '2012-10-17', Statement: parsedStatements }); return; } - await onAssign(rulesToPolicy(rules)); + await onAssign(BucketRulesToPolicy.toDocument(rules)); }; const isBusy = isLoading || isFetching || fetchError; diff --git a/src/sub-account/hooks/usePolicyEditor.ts b/src/sub-account/hooks/usePolicyEditor.ts index 2a61d71..24ac7bf 100644 --- a/src/sub-account/hooks/usePolicyEditor.ts +++ b/src/sub-account/hooks/usePolicyEditor.ts @@ -2,11 +2,9 @@ import { useEffect, useState } from 'react'; import { BucketRule, PolicyDocument, - builderCanRepresent, - isCustomPolicy, - parseStatements, - policyToRules, - rulesToJson, + PolicyStatement, + PolicyToBucketRules, + BucketRulesToPolicy, } from '../services/iamPolicy.service'; export interface PolicyEditorState { @@ -41,9 +39,10 @@ export const usePolicyEditor = ({ isOpen, onFetchPermissions }: Params) => { setLoadStatus('loading'); try { const policy = await onFetchPermissions(); - const { rules, droppedCount } = policyToRules(policy?.Statement ?? []); - // Unparseable or non-round-trippable: open in Advanced so nothing is dropped. - const custom = droppedCount > 0 || isCustomPolicy(rules); + const statements = policy?.Statement ?? []; + // Custom (builder can't represent it): open in Advanced so nothing is dropped. + const custom = PolicyToBucketRules.isCustom(statements); + const rules = PolicyToBucketRules.toRules(statements); setEditor({ rules, isAdvanced: custom, jsonText: custom && policy ? JSON.stringify(policy, null, 2) : '' }); setLoadStatus('idle'); } catch { @@ -58,20 +57,14 @@ export const usePolicyEditor = ({ isOpen, onFetchPermissions }: Params) => { }, [isOpen]); // Seed the editor from the current builder policy so edits start where the user was. - const enterAdvanced = () => patchEditor({ jsonText: rulesToJson(editor.rules), isAdvanced: true }); + const enterAdvanced = () => patchEditor({ jsonText: BucketRulesToPolicy.toJson(editor.rules), isAdvanced: true }); const setBuilderRules = (rules: BucketRule[]) => patchEditor({ rules, isAdvanced: false }); - // exited: false means the JSON is custom (builder can't represent it), so the - // caller must confirm a builder reset before switching. - const tryExitAdvanced = (): { exited: boolean } => { - const statements = parseStatements(editor.jsonText); - if (statements && builderCanRepresent(statements)) { - setBuilderRules(policyToRules(statements).rules); - return { exited: true }; - } - return { exited: false }; - }; + // Switch from the JSON editor back to the builder, seeding it from the current + // JSON. Only safe when the JSON isn't custom; otherwise the caller must reset. + const switchToBuilder = (statements: PolicyStatement[]) => + setBuilderRules(PolicyToBucketRules.toRules(statements)); const resetToBuilder = () => setBuilderRules([]); @@ -81,7 +74,7 @@ export const usePolicyEditor = ({ isOpen, onFetchPermissions }: Params) => { fetchError: loadStatus === 'error', patchEditor, enterAdvanced, - tryExitAdvanced, + switchToBuilder, resetToBuilder, }; }; diff --git a/src/sub-account/services/iamPolicy.service.ts b/src/sub-account/services/iamPolicy.service.ts index 7943104..1235ba8 100644 --- a/src/sub-account/services/iamPolicy.service.ts +++ b/src/sub-account/services/iamPolicy.service.ts @@ -11,14 +11,35 @@ export const S3_ACTIONS = { deleteObject: 's3:DeleteObject', listBucket: 's3:ListBucket', listAllMyBuckets: 's3:ListAllMyBuckets', + deleteBucket: 's3:DeleteBucket', + getBucketObjectLockConfiguration: 's3:GetBucketObjectLockConfiguration', + getBucketVersioning: 's3:GetBucketVersioning', + getBucketLogging: 's3:GetBucketLogging', + getBucketAcl: 's3:GetBucketAcl', + getObjectRetention: 's3:GetObjectRetention', + getObjectVersion: 's3:GetObjectVersion', + listBucketVersions: 's3:ListBucketVersions', all: 's3:*' } as const -export type AccessLevel = 'read' | 'write' | 'full' +const READ_ACTIONS: string[] = [ + S3_ACTIONS.getObject, + S3_ACTIONS.listBucket, + S3_ACTIONS.getBucketObjectLockConfiguration, + S3_ACTIONS.getBucketVersioning, + S3_ACTIONS.getBucketLogging, + S3_ACTIONS.getBucketAcl, + S3_ACTIONS.getObjectRetention, + S3_ACTIONS.getObjectVersion, + S3_ACTIONS.listBucketVersions +] + +export type AccessLevel = 'read' | 'write' | 'full' | 'full-limited' interface AccessLevelDefinition { label: string actions: string[] + deniedActions?: string[] // Account-level actions can't be scoped to a bucket, so they go in a '*' statement. accountActions: string[] } @@ -26,16 +47,18 @@ interface AccessLevelDefinition { export const ACCESS_LEVEL_CONFIG: Record = { read: { label: 'Read', - actions: [S3_ACTIONS.getObject, S3_ACTIONS.listBucket], + actions: READ_ACTIONS, accountActions: [S3_ACTIONS.listAllMyBuckets] }, write: { label: 'Write', - actions: [ - S3_ACTIONS.getObject, - S3_ACTIONS.putObject, - S3_ACTIONS.listBucket - ], + actions: [...READ_ACTIONS, S3_ACTIONS.putObject, S3_ACTIONS.deleteObject], + accountActions: [S3_ACTIONS.listAllMyBuckets] + }, + 'full-limited': { + label: 'Full limited', + actions: [S3_ACTIONS.all], + deniedActions: [S3_ACTIONS.deleteBucket], accountActions: [S3_ACTIONS.listAllMyBuckets] }, full: { @@ -50,27 +73,23 @@ export const ACCESS_LEVELS = Object.keys(ACCESS_LEVEL_CONFIG) as AccessLevel[] export const accessLevelLabel = (level: AccessLevel): string => ACCESS_LEVEL_CONFIG[level].label -export const accessLevelActions = (level: AccessLevel): string[] => - ACCESS_LEVEL_CONFIG[level].actions - export interface BucketRule { bucketName: string accessLevel: AccessLevel } -// Custom = the builder can't represent it, so it must be edited as raw JSON -export const isCustomPolicy = (rules: BucketRule[]): boolean => { - if (rules.length === 0) return true - const hasAllBuckets = rules.some(r => r.bucketName === ALL_BUCKETS) - return hasAllBuckets && rules.length > 1 -} - export interface PolicyStatement { Effect: 'Allow' | 'Deny' Action: string[] Resource: string[] } +// The builder only knows how to produce these fields. +const SUPPORTED_STATEMENT_FIELDS = new Set(['Sid', 'Effect', 'Action', 'Resource']) + +const hasUnsupportedFields = (statement: PolicyStatement): boolean => + Object.keys(statement).some(field => !SUPPORTED_STATEMENT_FIELDS.has(field)) + export interface PolicyDocument { Version: '2012-10-17' Statement: PolicyStatement[] @@ -82,153 +101,164 @@ const S3_ARN_RE = new RegExp(`^arn:aws:s3:::(${BUCKET_NAME})(?:\\/(.*))?$`) const isAllBucketsResource = (resource: string): boolean => resource === '*' || resource === 'arn:aws:s3:::*' -// ─── Rules → policy (the only direction we serialise) ─────────────────────── - -export const rulesToPolicy = (rules: BucketRule[]): PolicyDocument => { - const bucketStatements = rules.map(ruleToStatement) - - const accountActions = accountActionsFor(rules.map(rule => rule.accessLevel)) - const statements = - accountActions.length > 0 - ? [ - { Effect: 'Allow' as const, Action: accountActions, Resource: ['*'] }, - ...bucketStatements - ] - : bucketStatements +// ─── Builder rules → policy JSON ───────────────────────────────────────────── - return { Version: '2012-10-17', Statement: statements } -} +export class BucketRulesToPolicy { + static toDocument(rules: BucketRule[]): PolicyDocument { + const bucketStatements = rules.flatMap(rule => BucketRulesToPolicy.toStatements(rule)) -export const rulesToJson = (rules: BucketRule[]): string => - JSON.stringify(rulesToPolicy(rules), null, 2) + const accountActions = accountActionsFor(rules.map(rule => rule.accessLevel)) + const statements = + accountActions.length > 0 + ? [ + { Effect: 'Allow' as const, Action: accountActions, Resource: ['*'] }, + ...bucketStatements + ] + : bucketStatements -// Resource carries the bucket ARN (for actions like s3:ListBucket) plus its objects. -const ruleToStatement = (rule: BucketRule): PolicyStatement => { - if (rule.bucketName === ALL_BUCKETS) { - return { - Effect: 'Allow', - Action: accessLevelActions(rule.accessLevel), - Resource: ['arn:aws:s3:::*'] - } + return { Version: '2012-10-17', Statement: statements } } - const bucketArn = `arn:aws:s3:::${rule.bucketName}` - return { - Effect: 'Allow', - Action: accessLevelActions(rule.accessLevel), - Resource: [bucketArn, `${bucketArn}/*`] + static toJson(rules: BucketRule[]): string { + return JSON.stringify(BucketRulesToPolicy.toDocument(rules), null, 2) } -} -// ─── Policy → rules (read-only, best-effort) ──────────────────────────────── + // 'full-limited' adds a Deny on top of its Allow. + static toStatements(rule: BucketRule): PolicyStatement[] { + const bucketAndObjectsResource = + rule.bucketName === ALL_BUCKETS + ? ['arn:aws:s3:::*'] + : [`arn:aws:s3:::${rule.bucketName}`, `arn:aws:s3:::${rule.bucketName}/*`] -// Normalise Action/Resource to arrays as IAM policies allow single strings too. -const toStringArray = (value: unknown): string[] => - Array.isArray(value) ? value : value != null ? [value as string] : [] + const { actions, deniedActions } = ACCESS_LEVEL_CONFIG[rule.accessLevel] + const statements: PolicyStatement[] = [ + { Effect: 'Allow', Action: actions, Resource: bucketAndObjectsResource } + ] -export interface ParsedPolicy { - rules: BucketRule[] - // Statements the builder can't represent (Deny, unknown actions, prefixes, - // unparsable ARNs), counted so the UI can warn they'd be dropped on a rebuild. - droppedCount: number -} + if (deniedActions?.length) { + statements.push({ Effect: 'Deny', Action: deniedActions, Resource: bucketAndObjectsResource }) + } -export const policyToRules = (statements: PolicyStatement[]): ParsedPolicy => { - const rules: BucketRule[] = [] - let droppedCount = 0 + return statements + } +} - for (const rawStatement of statements) { - const statement = { - ...rawStatement, - Action: toStringArray(rawStatement.Action), - Resource: toStringArray(rawStatement.Resource) +// ─── Policy JSON → builder rules (read-only, best-effort) ──────────────────── +export class PolicyToBucketRules { + static parseJson(raw: string): PolicyStatement[] | null { + try { + const parsed = JSON.parse(raw) + if (!parsed || !Array.isArray(parsed.Statement)) { + return null + } + return (parsed.Statement as PolicyStatement[]).map(statement => ({ + ...statement, + Action: toStringArray(statement.Action), + Resource: toStringArray(statement.Resource) + })) + } catch { + return null } - if (isAccountStatement(statement)) { - continue - } - const rule = statementToRule(statement) - if (rule) { - rules.push(rule) - } else droppedCount++ } - return { rules, droppedCount } -} + // Custom = builder can't represent it + static isCustom(statements: PolicyStatement[]): boolean { + const { rules, hasCustomStatement } = PolicyToBucketRules.parse(statements) + if (hasCustomStatement || rules.length === 0) { + return true + } -// The '*' statement rulesToPolicy emits for account-level actions; skipped on -// parse so it isn't mistaken for a bucket rule or an unknown statement. -const isAccountStatement = (statement: PolicyStatement): boolean => { - const knownActions = accountActionsFor(ACCESS_LEVELS) - return ( - statement.Effect === 'Allow' && - statement.Resource.length === 1 && - isAllBucketsResource(statement.Resource[0]) && - statement.Action.length > 0 && - statement.Action.every(action => knownActions.includes(action)) - ) -} + const spansAllBuckets = rules.some(rule => rule.bucketName === ALL_BUCKETS) + return spansAllBuckets && rules.length > 1 + } -// Raw editor text → statements, or null if it isn't a policy with a Statement array. -export const parseStatements = (raw: string): PolicyStatement[] | null => { - try { - const parsed = JSON.parse(raw) - if (!parsed || !Array.isArray(parsed.Statement)) return null - return (parsed.Statement as PolicyStatement[]).map((statement) => ({ - ...statement, - Action: toStringArray(statement.Action), - Resource: toStringArray(statement.Resource) - })) - } catch { - return null + static toRules(statements: PolicyStatement[]): BucketRule[] { + return PolicyToBucketRules.parse(statements).rules } -} -// Whether the builder can show these statements losslessly (used to decide if -// switching to it is safe): nothing dropped and not a custom policy. -export const builderCanRepresent = (statements: PolicyStatement[]): boolean => { - const { rules, droppedCount } = policyToRules(statements) - return droppedCount === 0 && !isCustomPolicy(rules) -} + private static parse(statements: PolicyStatement[]): { rules: BucketRule[]; hasCustomStatement: boolean } { + const accountActions = accountActionsFor(ACCESS_LEVELS) + // The '*' account-level statement isn't a bucket rule, so skip it. + const isAccountStatement = (statement: PolicyStatement): boolean => + statement.Effect === 'Allow' && + statement.Resource.length === 1 && + isAllBucketsResource(statement.Resource[0]) && + statement.Action.length > 0 && + statement.Action.every(action => accountActions.includes(action)) + + const normalised = statements.map(rawStatement => ({ + ...rawStatement, + Action: toStringArray(rawStatement.Action), + Resource: toStringArray(rawStatement.Resource) + })) -const statementToRule = (statement: PolicyStatement): BucketRule | null => { - if (statement.Effect !== 'Allow') return null + // Group by Resource so an Allow and its paired Deny ('full-limited') become one rule. + const groups = new Map() + let hasCustomStatement = false + for (const statement of normalised) { + if (hasUnsupportedFields(statement)) { + hasCustomStatement = true + continue + } + if (isAccountStatement(statement)) { + continue + } + const resourceKey = canonical(statement.Resource) + const group = groups.get(resourceKey) ?? [] + group.push(statement) + groups.set(resourceKey, group) + } - const accessLevel = actionsToAccessLevel(statement.Action) - if (!accessLevel) return null + const rules: BucketRule[] = [] + for (const group of groups.values()) { + const rule = PolicyToBucketRules.toRule(group) + if (rule) { + rules.push(rule) + } else { + hasCustomStatement = true + } + } - if (statement.Resource.length === 1 && isAllBucketsResource(statement.Resource[0])) { - return { bucketName: ALL_BUCKETS, accessLevel } + return { rules, hasCustomStatement } } - const arnMatches = statement.Resource.map(resource => S3_ARN_RE.exec(resource)) - if (arnMatches.some(match => !match)) return null + // Matches a group against each level's serialised form; no match (or a Resource + // that isn't one whole bucket) = custom. + private static toRule(group: PolicyStatement[]): BucketRule | null { + const resource = group[0].Resource + let bucketName: string | null + if (resource.length === 1 && isAllBucketsResource(resource[0])) { + bucketName = ALL_BUCKETS + } else { + const arns = resource.map(r => S3_ARN_RE.exec(r)) + const buckets = new Set(arns.map(arn => arn?.[1])) + const hasObjectPrefix = arns.some(arn => (arn?.[2] ?? '').replace(/\*$/, '') !== '') + const spansOneParsableBucket = !buckets.has(undefined) && buckets.size === 1 + bucketName = spansOneParsableBucket && !hasObjectPrefix ? [...buckets][0]! : null + } + if (bucketName === null) { + return null + } - // One rule = one bucket; a statement spanning several can't be one row. - const bucketNames = [...new Set(arnMatches.map(match => match![1]))] - if (bucketNames.length !== 1) return null + const key = groupKey(group) + const accessLevel = ACCESS_LEVELS.find( + accessLevel => groupKey(BucketRulesToPolicy.toStatements({ bucketName, accessLevel })) === key + ) + return accessLevel ? { bucketName, accessLevel } : null + } +} - // The builder grants the whole bucket, so any object-prefix scoping is custom. - const hasPrefix = arnMatches.some(match => { - const objectPath = (match![2] ?? '').replace(/\*$/, '') - return objectPath !== '' - }) - if (hasPrefix) return null +// ─── Helpers ────────────────────────────────────── - return { bucketName: bucketNames[0], accessLevel } -} +// IAM policies allow a single string where an array is expected; normalise both. +const toStringArray = (value: unknown): string[] => + Array.isArray(value) ? value : value != null ? [value as string] : [] -// Matches only on an EXACT action set — any extra/missing action is rejected so -// rebuilding from the builder never silently drops actions we don't model. -const actionsToAccessLevel = (actions: string[]): AccessLevel | null => { - const actionSet = new Set(actions) - const matches = (expected: string[]) => - actionSet.size === expected.length && expected.every(action => actionSet.has(action)) +// A stable, order-insensitive string for a list, usable as a Map key or fingerprint. +const canonical = (values: string[]): string => [...values].sort().join('|') - for (const accessLevel of ACCESS_LEVELS) { - if (matches(accessLevelActions(accessLevel))) return accessLevel - } - return null -} +const groupKey = (statements: PolicyStatement[]): string => + canonical(statements.map(s => [s.Effect, canonical(s.Action), canonical(s.Resource)].join('#'))) const accountActionsFor = (levels: AccessLevel[]): string[] => [ ...new Set(levels.flatMap(level => ACCESS_LEVEL_CONFIG[level].accountActions)) diff --git a/src/sub-account/views/SubAccountBucketsPage.tsx b/src/sub-account/views/SubAccountBucketsPage.tsx index 4a4d7d2..b293711 100644 --- a/src/sub-account/views/SubAccountBucketsPage.tsx +++ b/src/sub-account/views/SubAccountBucketsPage.tsx @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDebounce } from 'use-debounce'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { DotsThreeVerticalIcon, @@ -22,7 +21,7 @@ import { S3Client } from '@aws-sdk/client-s3'; import { useSubAccount } from '../context/SubAccountContext'; import { T, shadow, text, form } from '../tokens'; import subAccountAxios from '../core/sub-account-axios'; -import { useObjectPagination } from '../hooks/useObjectPagination'; +import { S3Credentials } from '../services/sub-account-s3-credentials.service'; import { Pagination } from '../../components/Pagination'; import { DeleteBucketConfirmModal } from '../components/DeleteBucketConfirmModal'; import { BucketLoggingSetup } from '../../components/buckets/BucketLoggingSetup'; @@ -37,18 +36,26 @@ interface BucketRecord { // ─── Helpers ───────────────────────────────────────────────────────────────── -function fmtDate(d?: Date): string { - if (!d) { +function formatDate(date?: Date): string { + if (!date) { return '—'; } - const day = d.toLocaleDateString('en-GB', { day: '2-digit' }); - const month = d.toLocaleDateString('en-GB', { month: 'short' }); - const year = d.getFullYear(); - const time = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }); + const day = date.toLocaleDateString('en-GB', { day: '2-digit' }); + const month = date.toLocaleDateString('en-GB', { month: 'short' }); + const year = date.getFullYear(); + const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }); return `${day}-${month}-${year} ${time}`; } +const createRegionS3Client = (endpoint: string, region: string, credentials: S3Credentials) => + new S3Client({ + endpoint: `https://${endpoint}`, + region, + credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey }, + forcePathStyle: true, + }); + // ─── StatStrip ──────────────────────────────────────────────────────────────── interface StatItem { @@ -115,12 +122,11 @@ interface BucketRowProps { regionName: string; onOpen: () => void; onDelete: () => void; - isAdmin: boolean; } -const BucketRow = ({ bucket, regionName, onOpen, onDelete, isAdmin }: BucketRowProps) => { - const [hovered, setHovered] = useState(false); - const [triggerHovered, setTriggerHovered] = useState(false); +const BucketRow = ({ bucket, regionName, onOpen, onDelete }: BucketRowProps) => { + const [isRowHovered, setIsRowHovered] = useState(false); + const [isTriggerHovered, setIsTriggerHovered] = useState(false); return (
setHovered(true)} - onMouseLeave={() => setHovered(false)} + onMouseEnter={() => setIsRowHovered(true)} + onMouseLeave={() => setIsRowHovered(false)} onClick={onOpen} > {/* Name */} @@ -162,7 +168,7 @@ const BucketRow = ({ bucket, regionName, onOpen, onDelete, isAdmin }: BucketRowP fontSize: 14, fontWeight: 500, color: `${T.primary}`, - textDecoration: hovered ? 'underline' : 'none', + textDecoration: isRowHovered ? 'underline' : 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -176,28 +182,29 @@ const BucketRow = ({ bucket, regionName, onOpen, onDelete, isAdmin }: BucketRowP {regionName} {/* Created */} - {fmtDate(bucket.creationDate)} + {formatDate(bucket.creationDate)} {/* Actions */}
e.stopPropagation()} > - {hovered && isAdmin && ( + {/* TODO: hide based on user permissions once available */} + {isRowHovered && ( setTriggerHovered(true)} - onMouseLeave={() => setTriggerHovered(false)} + onMouseEnter={() => setIsTriggerHovered(true)} + onMouseLeave={() => setIsTriggerHovered(false)} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', - background: triggerHovered ? T.gray10 : 'transparent', + background: isTriggerHovered ? T.gray10 : 'transparent', borderRadius: 6, color: T.gray60, }} @@ -228,6 +235,7 @@ const BucketRow = ({ bucket, regionName, onOpen, onDelete, isAdmin }: BucketRowP interface BucketsTableProps { buckets: BucketRecord[]; + totalCount: number; regions: SubAccountRegion[]; isLoading: boolean; search: string; @@ -235,13 +243,13 @@ interface BucketsTableProps { onOpen: (bucket: BucketRecord) => void; onDelete: (bucket: BucketRecord) => void; onCreateOpen: () => void; - isAdmin: boolean; } const TABLE_HEADERS = ['Name', 'Region', 'Created', '']; const BucketsTable = ({ buckets, + totalCount, regions, isLoading, search, @@ -249,7 +257,6 @@ const BucketsTable = ({ onOpen, onDelete, onCreateOpen, - isAdmin, }: BucketsTableProps) => (

Buckets

- {buckets.length} {buckets.length === 1 ? 'bucket' : 'buckets'} total + {totalCount} {totalCount === 1 ? 'bucket' : 'buckets'} total

@@ -287,29 +294,14 @@ const BucketsTable = ({ onClear={() => onSearchChange('')} />
- {isAdmin && ( - - )} + {/* TODO: hide based on user permissions once available */} +
@@ -365,7 +357,6 @@ const BucketsTable = ({ regionName={region?.name ?? b.regionSlug ?? '—'} onOpen={() => onOpen(b)} onDelete={() => onDelete(b)} - isAdmin={isAdmin} /> ); }) @@ -379,47 +370,31 @@ interface CreateBucketModalProps { isOpen: boolean; onClose: () => void; regions: SubAccountRegion[]; - credentials: { accessKeyId: string; secretAccessKey: string } | null; - loadBuckets: (options?: { signal?: AbortSignal }) => Promise; + credentials: S3Credentials | null; + bucketNames: string[]; onCreated: () => void; } const INITIAL_FORM_STATE = { bucketName: '', - selectedSlug: '', + selectedRegionSlug: '', versioningEnabled: false, objectLockEnabled: false, logging: { enabled: false, prefix: '', target: '' }, }; const CreateBucketModal = ({ - isOpen, onClose, regions, credentials, loadBuckets, onCreated, + isOpen, onClose, regions, credentials, bucketNames, onCreated, }: CreateBucketModalProps) => { const [formState, setFormState] = useState(INITIAL_FORM_STATE); const [isCreating, setIsCreating] = useState(false); - const [availableBucketNames, setAvailableBucketNames] = useState([]); - const { bucketName, selectedSlug, versioningEnabled, objectLockEnabled, logging } = formState; + const { bucketName, selectedRegionSlug, versioningEnabled, objectLockEnabled, logging } = formState; const updateForm = (patch: Partial) => setFormState((prev) => ({ ...prev, ...patch })); const canSubmitLogging = !logging.enabled || (!!logging.target && !!logging.prefix.trim()); - useEffect(() => { - if (!isOpen) { - return; - } - const controller = new AbortController(); - loadBuckets({ signal: controller.signal }) - .then(setAvailableBucketNames) - .catch((err) => { - if ((err as Error).name !== 'AbortError') { - notificationsService.error({ text: (err as Error).message }); - } - }); - return () => controller.abort(); - }, [isOpen, loadBuckets]); - useEffect(() => { if (isOpen) { return; @@ -427,13 +402,13 @@ const CreateBucketModal = ({ setFormState(INITIAL_FORM_STATE); }, [isOpen]); - const onToggleVersioning = (next: boolean) => { - updateForm({ versioningEnabled: next, ...(next ? {} : { objectLockEnabled: false }) }); + const onToggleVersioning = (enabled: boolean) => { + updateForm({ versioningEnabled: enabled, ...(enabled ? {} : { objectLockEnabled: false }) }); }; const selectedRegion = useMemo( - () => regions.find((r) => r.slug === selectedSlug) ?? regions[0] ?? null, - [regions, selectedSlug], + () => regions.find((r) => r.slug === selectedRegionSlug) ?? regions[0] ?? null, + [regions, selectedRegionSlug], ); const handleCreate = async () => { @@ -441,12 +416,7 @@ const CreateBucketModal = ({ return; } setIsCreating(true); - const regionClient = new S3Client({ - endpoint: `https://${selectedRegion.endpoint}`, - region: selectedRegion.slug, - credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey }, - forcePathStyle: true, - }); + const regionClient = createRegionS3Client(selectedRegion.endpoint, selectedRegion.slug, credentials); try { await s3Service.createBucket(regionClient, bucketName, selectedRegion.slug, objectLockEnabled); if (versioningEnabled) { @@ -517,7 +487,7 @@ const CreateBucketModal = ({