This is an automated email from the ASF dual-hosted git repository.

rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 69de5198a6 NIFI-14607, NIFI-14608, NIFI-15052 Manage bucket policies 
(add, edit,… (#10399)
69de5198a6 is described below

commit 69de5198a6643d174fccf7c0ae122594196a4763
Author: Scott Aslan <[email protected]>
AuthorDate: Thu Nov 6 08:17:03 2025 -0500

    NIFI-14607, NIFI-14608, NIFI-15052 Manage bucket policies (add, edit,… 
(#10399)
    
    * NIFI-14607, NIFI-14608, NIFI-15052 Manage bucket policies (add, edit, 
delete)
    
    * address review feedback
    
    * address review feedback
    
    * add type column to new policy dialog
    
    * show policiy loading error in global banner and dialog does not open
    
    * identity column width
    
    * type column width
    
    * fix unit test
    
    This closes #10399
---
 .../app/pages/buckets/feature/buckets.module.ts    |   3 +-
 .../add-policy-to-bucket-dialog.component.html     |  84 +++++
 .../add-policy-to-bucket-dialog.component.scss     |  28 ++
 .../add-policy-to-bucket-dialog.component.spec.ts  | 213 +++++++++++++
 .../add-policy-to-bucket-dialog.component.ts       | 175 +++++++++++
 .../edit-policy-dialog.component.html              |  48 +++
 .../edit-policy-dialog.component.scss              |  18 ++
 .../edit-policy-dialog.component.spec.ts           | 138 ++++++++
 .../edit-policy-dialog.component.ts                |  90 ++++++
 .../manage-bucket-policies-dialog.component.html   | 102 +++++-
 ...manage-bucket-policies-dialog.component.spec.ts | 326 +++++++++++++++++++
 .../manage-bucket-policies-dialog.component.ts     | 348 ++++++++++++++++++++-
 .../ui/login-form/login-form.component.spec.ts     |  18 +-
 .../resources/feature/resources.component.html     |   7 +-
 .../pages/resources/feature/resources.component.ts |   8 +
 .../pages/resources/feature/resources.module.ts    |  11 +-
 .../ui/droplet-table/droplet-table.component.html  |  15 +-
 .../ui/droplet-table/droplet-table.component.ts    |  14 +-
 .../src/app/service/buckets.service.ts             | 117 ++++++-
 .../src/app/state/buckets/buckets.actions.ts       |   2 -
 .../src/app/state/buckets/buckets.effects.spec.ts  |  40 ++-
 .../src/app/state/buckets/buckets.effects.ts       | 117 ++++++-
 .../src/app/state/buckets/buckets.selectors.ts     |   1 -
 .../current-user/current-user.effects.spec.ts      |   3 -
 .../nifi-registry/src/app/state/error/index.ts     |   1 +
 .../apps/nifi-registry/src/app/state/index.ts      |   6 +-
 .../nifi-registry/src/app/state/policies/index.ts  |  49 +++
 .../src/app/state/policies/policies.actions.ts     |  69 ++++
 .../app/state/policies/policies.effects.spec.ts    | 276 ++++++++++++++++
 .../src/app/state/policies/policies.effects.ts     | 146 +++++++++
 .../src/app/state/policies/policies.reducer.ts     | 131 ++++++++
 .../src/app/state/policies/policies.selectors.ts   |  73 +++++
 .../flow-registry-client-definition.component.html |   4 +-
 .../local-changes-table/local-changes-table.ts     |   2 +-
 34 files changed, 2625 insertions(+), 58 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
index d223afaeb3..9e0d697a39 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
@@ -23,6 +23,7 @@ import { EffectsModule } from '@ngrx/effects';
 import { BucketsRoutingModule } from './buckets-routing.module';
 import { BucketsComponent } from './buckets.component';
 import { BucketsEffects } from '../../../state/buckets/buckets.effects';
+import { PoliciesEffects } from '../../../state/policies/policies.effects';
 import { reducers, resourcesFeatureKey } from '../../../state';
 import { MatTableModule } from '@angular/material/table';
 import { MatSortModule } from '@angular/material/sort';
@@ -67,7 +68,7 @@ import { HeaderComponent } from 
'../../../ui/header/header.component';
         MatDialogModule,
         BucketsRoutingModule,
         StoreModule.forFeature(resourcesFeatureKey, reducers),
-        EffectsModule.forFeature([BucketsEffects]),
+        EffectsModule.forFeature([BucketsEffects, PoliciesEffects]),
         HeaderComponent
     ]
 })
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.html
new file mode 100644
index 0000000000..b4b5831c83
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.html
@@ -0,0 +1,84 @@
+<!--
+~  Licensed to the Apache Software Foundation (ASF) under one or more
+~  contributor license agreements.  See the NOTICE file distributed with
+~  this work for additional information regarding copyright ownership.
+~  The ASF licenses this file to You under the Apache License, Version 2.0
+~  (the "License"); you may not use this file except in compliance with
+~  the License.  You may obtain a copy of the License at
+~
+~     http://www.apache.org/licenses/LICENSE-2.0
+~
+~  Unless required by applicable law or agreed to in writing, software
+~  distributed under the License is distributed on an "AS IS" BASIS,
+~  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+~  See the License for the specific language governing permissions and
+~  limitations under the License.
+-->
+
+<h2 mat-dialog-title>New Policy</h2>
+<mat-dialog-content class="dialog-content">
+    <div class="add-policy-to-bucket-dialog flex flex-col h-full gap-y-4">
+        <div class="listing-table select-none flex flex-1">
+            <div class="flex-1 relative">
+                <div class="absolute inset-0 overflow-y-auto 
overflow-x-hidden">
+                    <table
+                        mat-table
+                        [dataSource]="dataSource"
+                        matSort
+                        matSortDisableClear
+                        (matSortChange)="sortData($event)"
+                        [matSortActive]="sort.active"
+                        [matSortDirection]="sort.direction">
+                        <!-- Type Column -->
+                        <ng-container matColumnDef="type">
+                            <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Type</th>
+                            <td mat-cell *matCellDef="let row">
+                                {{ row.type }}
+                            </td>
+                        </ng-container>
+                        <!-- Identity Column -->
+                        <ng-container matColumnDef="identity">
+                            <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Identity</th>
+                            <td mat-cell *matCellDef="let row">
+                                <div class="flex items-center gap-x-2">
+                                    <div
+                                        class="overflow-ellipsis 
overflow-hidden whitespace-nowrap"
+                                        [title]="row.identity">
+                                        {{ row.identity }}
+                                    </div>
+                                </div>
+                            </td>
+                        </ng-container>
+                        <tr mat-header-row *matHeaderRowDef="displayedColumns; 
sticky: true"></tr>
+                        <tr
+                            mat-row
+                            *matRowDef="let row; let even = even; columns: 
displayedColumns"
+                            [class.even]="even"
+                            [class.selected]="isSelected(row)"
+                            (click)="selectRow(row)"></tr>
+                    </table>
+                </div>
+            </div>
+        </div>
+
+        <div class="flex items-center justify-between">
+            <mat-checkbox [checked]="allChecked" 
(change)="toggleAll($event.checked)">
+                <span class="mat-body-1">All</span>
+            </mat-checkbox>
+            <mat-checkbox [(ngModel)]="readChecked">
+                <span class="mat-body-1">Read</span>
+            </mat-checkbox>
+            <mat-checkbox [(ngModel)]="writeChecked">
+                <span class="mat-body-1">Write</span>
+            </mat-checkbox>
+            <mat-checkbox [(ngModel)]="deleteChecked">
+                <span class="mat-body-1">Delete</span>
+            </mat-checkbox>
+        </div>
+    </div>
+</mat-dialog-content>
+
+<mat-dialog-actions align="end">
+    <button mat-button (click)="cancel()">Cancel</button>
+    <button mat-flat-button color="primary" (click)="apply()" 
[disabled]="!canApply">Apply</button>
+</mat-dialog-actions>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.scss
new file mode 100644
index 0000000000..4901802b89
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.scss
@@ -0,0 +1,28 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.add-policy-to-bucket-dialog {
+    .listing-table {
+        table {
+            .mat-column-type {
+                width: 20%;
+            }
+        }
+    }
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.spec.ts
new file mode 100644
index 0000000000..89c8ca6f78
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.spec.ts
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AddPolicyToBucketDialogComponent, AddPolicyToBucketDialogData } from 
'./add-policy-to-bucket-dialog.component';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { PolicySubject } from 
'apps/nifi-registry/src/app/service/buckets.service';
+import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+
+const bucket: Bucket = {
+    allowBundleRedeploy: false,
+    allowPublicRead: false,
+    createdTimestamp: Date.now(),
+    description: 'Test Bucket',
+    identifier: 'bucket-1',
+    link: { href: '', params: { rel: '' } },
+    name: 'Test Bucket',
+    permissions: { canRead: true, canWrite: true },
+    revision: { version: 1 }
+};
+
+const user1: PolicySubject = {
+    identifier: 'user-1',
+    identity: 'alice',
+    type: 'user',
+    configurable: false
+};
+
+const user2: PolicySubject = {
+    identifier: 'user-2',
+    identity: 'bob',
+    type: 'user',
+    configurable: false
+};
+
+const group1: PolicySubject = {
+    identifier: 'group-1',
+    identity: 'admins',
+    type: 'group',
+    configurable: false
+};
+
+describe('AddPolicyToBucketDialogComponent', () => {
+    let component: AddPolicyToBucketDialogComponent;
+    let fixture: ComponentFixture<AddPolicyToBucketDialogComponent>;
+    let dialogRef: jest.Mocked<MatDialogRef<AddPolicyToBucketDialogComponent>>;
+
+    beforeEach(async () => {
+        const mockDialogRef = {
+            close: jest.fn()
+        };
+
+        await TestBed.configureTestingModule({
+            imports: [AddPolicyToBucketDialogComponent, NoopAnimationsModule],
+            providers: [
+                {
+                    provide: MAT_DIALOG_DATA,
+                    useValue: {
+                        bucket,
+                        existingUsers: [],
+                        existingGroups: [],
+                        availableUsers: [user1, user2],
+                        availableGroups: [group1]
+                    } as AddPolicyToBucketDialogData
+                },
+                { provide: MatDialogRef, useValue: mockDialogRef }
+            ]
+        }).compileComponents();
+
+        fixture = TestBed.createComponent(AddPolicyToBucketDialogComponent);
+        component = fixture.componentInstance;
+        dialogRef = TestBed.inject(MatDialogRef) as 
jest.Mocked<MatDialogRef<AddPolicyToBucketDialogComponent>>;
+        fixture.detectChanges();
+    });
+
+    it('should create', () => {
+        expect(component).toBeTruthy();
+    });
+
+    it('should initialize with available users and groups', () => {
+        expect(component.dataSource.data.length).toBe(3); // 2 users + 1 group
+        expect(component.dataSource.data[0].type).toBe('group'); // Groups 
sorted first
+        expect(component.dataSource.data[1].identity).toBe('alice');
+        expect(component.dataSource.data[2].identity).toBe('bob');
+    });
+
+    it('should filter out existing users and groups', () => {
+        // Re-initialize component with different data by updating the data 
source
+        component['data'] = {
+            bucket,
+            existingUsers: ['alice'],
+            existingGroups: ['admins'],
+            availableUsers: [user1, user2],
+            availableGroups: [group1]
+        };
+
+        component.ngOnInit();
+
+        expect(component.dataSource.data.length).toBe(1); // Only bob
+        expect(component.dataSource.data[0].identity).toBe('bob');
+    });
+
+    it('should select a row when clicked', () => {
+        const row = component.dataSource.data[1];
+        component.selectRow(row);
+
+        expect(component.selectedRow).toBe(row);
+        expect(row.selected).toBe(true);
+    });
+
+    it('should deselect other rows when selecting a new row', () => {
+        const row1 = component.dataSource.data[0];
+        const row2 = component.dataSource.data[1];
+
+        component.selectRow(row1);
+        expect(row1.selected).toBe(true);
+
+        component.selectRow(row2);
+        expect(row1.selected).toBe(false);
+        expect(row2.selected).toBe(true);
+        expect(component.selectedRow).toBe(row2);
+    });
+
+    it('should toggle all permissions when toggleAll is called', () => {
+        component.toggleAll(true);
+        expect(component.readChecked).toBe(true);
+        expect(component.writeChecked).toBe(true);
+        expect(component.deleteChecked).toBe(true);
+
+        component.toggleAll(false);
+        expect(component.readChecked).toBe(false);
+        expect(component.writeChecked).toBe(false);
+        expect(component.deleteChecked).toBe(false);
+    });
+
+    it('should calculate allChecked getter correctly', () => {
+        component.readChecked = true;
+        component.writeChecked = true;
+        component.deleteChecked = true;
+        expect(component.allChecked).toBe(true);
+
+        component.deleteChecked = false;
+        expect(component.allChecked).toBe(false);
+    });
+
+    it('should disable apply when no row selected', () => {
+        component.readChecked = true;
+        expect(component.canApply).toBe(false);
+    });
+
+    it('should disable apply when no permissions checked', () => {
+        component.selectRow(component.dataSource.data[0]);
+        component.readChecked = false;
+        component.writeChecked = false;
+        component.deleteChecked = false;
+        expect(component.canApply).toBe(false);
+    });
+
+    it('should enable apply when row selected and permission checked', () => {
+        component.selectRow(component.dataSource.data[0]);
+        component.readChecked = true;
+        expect(component.canApply).toBe(true);
+    });
+
+    it('should close dialog with result when apply is clicked', () => {
+        const row = component.dataSource.data[1]; // alice
+        component.selectRow(row);
+        component.readChecked = true;
+        component.writeChecked = true;
+
+        component.apply();
+
+        expect(dialogRef.close).toHaveBeenCalledWith({
+            userOrGroup: {
+                identifier: 'user-1',
+                identity: 'alice',
+                type: 'user'
+            },
+            permissions: ['read', 'write']
+        });
+    });
+
+    it('should not apply when no row selected', () => {
+        component.readChecked = true;
+        component.apply();
+        expect(dialogRef.close).not.toHaveBeenCalled();
+    });
+
+    it('should close dialog without result when cancel is clicked', () => {
+        component.cancel();
+        expect(dialogRef.close).toHaveBeenCalledWith();
+    });
+
+    it('should sort data correctly', () => {
+        component.sortData({ active: 'identity', direction: 'desc' });
+        expect(component.dataSource.data[0].identity).toBe('bob'); // 
Descending order
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.ts
new file mode 100644
index 0000000000..1269e1d5b0
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.ts
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from 
'@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { MatSortModule, Sort } from '@angular/material/sort';
+import { FormsModule } from '@angular/forms';
+import { NiFiCommon } from '@nifi/shared';
+import { PolicySubject } from 
'apps/nifi-registry/src/app/service/buckets.service';
+import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+import { PolicySection } from 'apps/nifi-registry/src/app/state/policies';
+
+export interface AddPolicyToBucketDialogData {
+    bucket: Bucket;
+    existingUsers: string[];
+    existingGroups: string[];
+    availableUsers: PolicySubject[];
+    availableGroups: PolicySubject[];
+}
+
+export interface UserOrGroupRow {
+    identity: string;
+    identifier: string;
+    type: 'user' | 'group';
+    selected: boolean;
+}
+
+export interface AddPolicyResult {
+    userOrGroup: PolicySubject;
+    permissions: PolicySection[];
+}
+
+@Component({
+    selector: 'add-policy-to-bucket-dialog',
+    templateUrl: './add-policy-to-bucket-dialog.component.html',
+    styleUrl: './add-policy-to-bucket-dialog.component.scss',
+    standalone: true,
+    imports: [MatDialogModule, MatButtonModule, MatCheckboxModule, 
MatTableModule, MatSortModule, FormsModule]
+})
+export class AddPolicyToBucketDialogComponent implements OnInit {
+    protected data = inject<AddPolicyToBucketDialogData>(MAT_DIALOG_DATA);
+    private dialogRef = inject(MatDialogRef<AddPolicyToBucketDialogComponent>);
+    private nifiCommon = inject(NiFiCommon);
+
+    dataSource: MatTableDataSource<UserOrGroupRow> = new 
MatTableDataSource<UserOrGroupRow>();
+    displayedColumns: string[] = ['type', 'identity'];
+    sort: Sort = {
+        active: 'identity',
+        direction: 'asc'
+    };
+
+    selectedRow: UserOrGroupRow | null = null;
+
+    // Permissions
+    readChecked = false;
+    writeChecked = false;
+    deleteChecked = false;
+
+    get allChecked(): boolean {
+        return this.readChecked && this.writeChecked && this.deleteChecked;
+    }
+
+    get canApply(): boolean {
+        return !!this.selectedRow && (this.readChecked || this.writeChecked || 
this.deleteChecked);
+    }
+
+    ngOnInit(): void {
+        // Filter out users and groups that already have policies as users can 
only create or edit a single policy for a user or group per bucket
+        const availableUsers = this.data.availableUsers
+            .filter((user) => !this.data.existingUsers.includes(user.identity))
+            .map((user) => ({
+                identity: user.identity,
+                identifier: user.identifier,
+                type: 'user' as const,
+                selected: false
+            }));
+
+        const availableGroups = this.data.availableGroups
+            .filter((group) => 
!this.data.existingGroups.includes(group.identity))
+            .map((group) => ({
+                identity: group.identity,
+                identifier: group.identifier,
+                type: 'group' as const,
+                selected: false
+            }));
+
+        const allRows = [...availableGroups, ...availableUsers];
+        this.dataSource.data = this.sortRows(allRows, this.sort);
+    }
+
+    sortData(sort: Sort) {
+        this.sort = sort;
+        this.dataSource.data = this.sortRows(this.dataSource.data, sort);
+    }
+
+    sortRows(data: UserOrGroupRow[], sort: Sort): UserOrGroupRow[] {
+        if (!data) {
+            return [];
+        }
+        return data.slice().sort((a, b) => {
+            const isAsc = sort.direction === 'asc';
+            let retVal = 0;
+            switch (sort.active) {
+                case 'identity':
+                    retVal = this.nifiCommon.compareString(a.identity, 
b.identity);
+                    break;
+                case 'type':
+                    retVal = this.nifiCommon.compareString(a.type, b.type);
+                    break;
+            }
+            return retVal * (isAsc ? 1 : -1);
+        });
+    }
+
+    selectRow(row: UserOrGroupRow): void {
+        // Deselect all rows
+        this.dataSource.data.forEach((r) => (r.selected = false));
+        // Select clicked row
+        row.selected = true;
+        this.selectedRow = row;
+    }
+
+    isSelected(row: UserOrGroupRow): boolean {
+        return row.selected;
+    }
+
+    toggleAll(checked: boolean): void {
+        this.readChecked = checked;
+        this.writeChecked = checked;
+        this.deleteChecked = checked;
+    }
+
+    apply(): void {
+        if (!this.selectedRow) {
+            return;
+        }
+
+        const permissions: PolicySection[] = [];
+        if (this.readChecked) permissions.push('read');
+        if (this.writeChecked) permissions.push('write');
+        if (this.deleteChecked) permissions.push('delete');
+
+        const result: AddPolicyResult = {
+            userOrGroup: {
+                identifier: this.selectedRow.identifier,
+                identity: this.selectedRow.identity,
+                type: this.selectedRow.type
+            },
+            permissions
+        };
+
+        this.dialogRef.close(result);
+    }
+
+    cancel(): void {
+        this.dialogRef.close();
+    }
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.html
new file mode 100644
index 0000000000..9c8ad3e767
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.html
@@ -0,0 +1,48 @@
+<!--
+~  Licensed to the Apache Software Foundation (ASF) under one or more
+~  contributor license agreements.  See the NOTICE file distributed with
+~  this work for additional information regarding copyright ownership.
+~  The ASF licenses this file to You under the Apache License, Version 2.0
+~  (the "License"); you may not use this file except in compliance with
+~  the License.  You may obtain a copy of the License at
+~
+~     http://www.apache.org/licenses/LICENSE-2.0
+~
+~  Unless required by applicable law or agreed to in writing, software
+~  distributed under the License is distributed on an "AS IS" BASIS,
+~  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+~  See the License for the specific language governing permissions and
+~  limitations under the License.
+-->
+
+<h2 mat-dialog-title>Edit Policy</h2>
+<mat-dialog-content>
+    <div class="flex flex-col gap-y-2">
+        <div class="mt-2">
+            <mat-form-field class="w-full">
+                <mat-label>For This {{ data.type === 'user' ? 'User' : 'Group' 
}}</mat-label>
+                <input matInput [value]="data.identity" disabled />
+            </mat-form-field>
+        </div>
+
+        <div class="flex items-center justify-between">
+            <mat-checkbox [checked]="allChecked" 
(change)="toggleAll($event.checked)">
+                <span class="mat-body-1">All</span>
+            </mat-checkbox>
+            <mat-checkbox [(ngModel)]="readChecked">
+                <span class="mat-body-1">Read</span>
+            </mat-checkbox>
+            <mat-checkbox [(ngModel)]="writeChecked">
+                <span class="mat-body-1">Write</span>
+            </mat-checkbox>
+            <mat-checkbox [(ngModel)]="deleteChecked">
+                <span class="mat-body-1">Delete</span>
+            </mat-checkbox>
+        </div>
+    </div>
+</mat-dialog-content>
+
+<mat-dialog-actions align="end">
+    <button mat-button (click)="cancel()">Cancel</button>
+    <button mat-flat-button color="primary" (click)="apply()" 
[disabled]="!canApply">Apply</button>
+</mat-dialog-actions>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.scss
new file mode 100644
index 0000000000..3d56d22bb3
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.scss
@@ -0,0 +1,18 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.spec.ts
new file mode 100644
index 0000000000..64f1161f28
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.spec.ts
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { EditPolicyDialogComponent, EditPolicyDialogData } from 
'./edit-policy-dialog.component';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+
+describe('EditPolicyDialogComponent', () => {
+    let component: EditPolicyDialogComponent;
+    let fixture: ComponentFixture<EditPolicyDialogComponent>;
+    let dialogRef: jest.Mocked<MatDialogRef<EditPolicyDialogComponent>>;
+
+    beforeEach(async () => {
+        const mockDialogRef = {
+            close: jest.fn()
+        };
+
+        await TestBed.configureTestingModule({
+            imports: [EditPolicyDialogComponent, NoopAnimationsModule],
+            providers: [
+                {
+                    provide: MAT_DIALOG_DATA,
+                    useValue: {
+                        identity: 'alice',
+                        type: 'user',
+                        currentPermissions: ['read', 'write']
+                    } as EditPolicyDialogData
+                },
+                { provide: MatDialogRef, useValue: mockDialogRef }
+            ]
+        }).compileComponents();
+
+        fixture = TestBed.createComponent(EditPolicyDialogComponent);
+        component = fixture.componentInstance;
+        dialogRef = TestBed.inject(MatDialogRef) as 
jest.Mocked<MatDialogRef<EditPolicyDialogComponent>>;
+        fixture.detectChanges();
+    });
+
+    it('should create', () => {
+        expect(component).toBeTruthy();
+    });
+
+    it('should initialize with current permissions checked', () => {
+        expect(component.readChecked).toBe(true);
+        expect(component.writeChecked).toBe(true);
+        expect(component.deleteChecked).toBe(false);
+    });
+
+    it('should calculate allChecked correctly', () => {
+        component.readChecked = true;
+        component.writeChecked = true;
+        component.deleteChecked = true;
+        expect(component.allChecked).toBe(true);
+
+        component.readChecked = false;
+        expect(component.allChecked).toBe(false);
+    });
+
+    it('should toggle all permissions', () => {
+        component.toggleAll(true);
+        expect(component.readChecked).toBe(true);
+        expect(component.writeChecked).toBe(true);
+        expect(component.deleteChecked).toBe(true);
+
+        component.toggleAll(false);
+        expect(component.readChecked).toBe(false);
+        expect(component.writeChecked).toBe(false);
+        expect(component.deleteChecked).toBe(false);
+    });
+
+    it('should disable apply when no permissions checked', () => {
+        component.readChecked = false;
+        component.writeChecked = false;
+        component.deleteChecked = false;
+        expect(component.canApply).toBe(false);
+    });
+
+    it('should enable apply when at least one permission checked', () => {
+        component.readChecked = true;
+        expect(component.canApply).toBe(true);
+    });
+
+    it('should close dialog with updated permissions when apply clicked', () 
=> {
+        component.deleteChecked = true; // Add delete permission
+        component.apply();
+
+        expect(dialogRef.close).toHaveBeenCalledWith({
+            permissions: ['read', 'write', 'delete']
+        });
+    });
+
+    it('should return only checked permissions', () => {
+        component.readChecked = true;
+        component.writeChecked = false;
+        component.deleteChecked = true;
+
+        component.apply();
+
+        expect(dialogRef.close).toHaveBeenCalledWith({
+            permissions: ['read', 'delete']
+        });
+    });
+
+    it('should close dialog without result when cancel clicked', () => {
+        component.cancel();
+        expect(dialogRef.close).toHaveBeenCalledWith();
+    });
+
+    it('should handle group type correctly', () => {
+        component['data'] = {
+            identity: 'admins',
+            type: 'group',
+            currentPermissions: ['delete']
+        };
+
+        component.ngOnInit();
+
+        expect(component['data'].type).toBe('group');
+        expect(component.deleteChecked).toBe(true);
+        expect(component.readChecked).toBe(false);
+        expect(component.writeChecked).toBe(false);
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.ts
new file mode 100644
index 0000000000..92c503780b
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.ts
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from 
'@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { FormsModule } from '@angular/forms';
+import { PolicySection } from 'apps/nifi-registry/src/app/state/policies';
+
+export interface EditPolicyDialogData {
+    identity: string;
+    type: 'user' | 'group';
+    currentPermissions: PolicySection[];
+}
+
+export interface EditPolicyResult {
+    permissions: PolicySection[];
+}
+
+@Component({
+    selector: 'edit-policy-dialog',
+    templateUrl: './edit-policy-dialog.component.html',
+    styleUrl: './edit-policy-dialog.component.scss',
+    standalone: true,
+    imports: [MatDialogModule, MatButtonModule, MatCheckboxModule, 
MatFormFieldModule, MatInputModule, FormsModule]
+})
+export class EditPolicyDialogComponent implements OnInit {
+    protected data = inject<EditPolicyDialogData>(MAT_DIALOG_DATA);
+    private dialogRef = inject(MatDialogRef<EditPolicyDialogComponent>);
+
+    // Permissions
+    readChecked = false;
+    writeChecked = false;
+    deleteChecked = false;
+
+    get allChecked(): boolean {
+        return this.readChecked && this.writeChecked && this.deleteChecked;
+    }
+
+    get canApply(): boolean {
+        return this.readChecked || this.writeChecked || this.deleteChecked;
+    }
+
+    ngOnInit(): void {
+        // Initialize checkboxes based on current permissions
+        this.readChecked = this.data.currentPermissions.includes('read');
+        this.writeChecked = this.data.currentPermissions.includes('write');
+        this.deleteChecked = this.data.currentPermissions.includes('delete');
+    }
+
+    toggleAll(checked: boolean): void {
+        this.readChecked = checked;
+        this.writeChecked = checked;
+        this.deleteChecked = checked;
+    }
+
+    apply(): void {
+        const permissions: PolicySection[] = [];
+        if (this.readChecked) permissions.push('read');
+        if (this.writeChecked) permissions.push('write');
+        if (this.deleteChecked) permissions.push('delete');
+
+        const result: EditPolicyResult = {
+            permissions
+        };
+
+        this.dialogRef.close(result);
+    }
+
+    cancel(): void {
+        this.dialogRef.close();
+    }
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
index 7cf225b7ec..d7d1887d9d 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
@@ -15,16 +15,100 @@
 ~  limitations under the License.
 -->
 
-<h2 mat-dialog-title>Manage Bucket Policies</h2>
-<mat-dialog-content>
-    <div class="flex flex-col gap-y-4">
-        <p>
-            TODO: Manage policies for bucket: <strong>{{ data.bucket.name 
}}</strong>
-        </p>
-        <!--        TODO: Manage policies for bucket         -->
-    </div>
+<h2 mat-dialog-title>{{ data.bucket.name }}<span 
class="ml-2">Policies</span></h2>
+<mat-dialog-content class="dialog-content">
+    <context-error-banner 
[context]="ErrorContextKey.MANAGE_ACCESS"></context-error-banner>
+    @if (!(isPolicyError$ | async)) {
+        <div class="policy-table flex flex-col gap-y-3 h-full">
+            <div class="flex justify-between items-center">
+                <div class="flex justify-end w-full">
+                    <button
+                        mat-icon-button
+                        class="primary-icon-button"
+                        (click)="addPolicy()"
+                        [disabled]="(loading$ | async) || 
(isAddPolicyDisabled$ | async)">
+                        <i class="fa fa-plus"></i>
+                    </button>
+                </div>
+            </div>
+            <div class="listing-table select-none flex flex-1">
+                <div class="flex-1 relative">
+                    <div class="absolute inset-0 overflow-y-auto 
overflow-x-hidden">
+                        <table
+                            mat-table
+                            [dataSource]="dataSource"
+                            matSort
+                            matSortDisableClear
+                            (matSortChange)="sortData($event)"
+                            [matSortActive]="sort.active"
+                            [matSortDirection]="sort.direction">
+                            <!-- Type Column -->
+                            <ng-container matColumnDef="type">
+                                <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Type</th>
+                                <td mat-cell *matCellDef="let row">
+                                    {{ row.type }}
+                                </td>
+                            </ng-container>
+                            <!-- Identity Column -->
+                            <ng-container matColumnDef="identity">
+                                <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Identity</th>
+                                <td mat-cell *matCellDef="let row">
+                                    <div
+                                        class="overflow-ellipsis 
overflow-hidden whitespace-nowrap"
+                                        [title]="row.identity">
+                                        {{ row.identity }}
+                                    </div>
+                                </td>
+                            </ng-container>
+                            <!-- Permissions Column -->
+                            <ng-container matColumnDef="permissions">
+                                <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Permissions</th>
+                                <td mat-cell *matCellDef="let row">
+                                    <div
+                                        class="overflow-ellipsis 
overflow-hidden whitespace-nowrap"
+                                        [title]="row.permissions">
+                                        {{ row.permissions }}
+                                    </div>
+                                </td>
+                            </ng-container>
+                            <!-- Actions Column -->
+                            <ng-container matColumnDef="actions">
+                                <th mat-header-cell *matHeaderCellDef></th>
+                                <td mat-cell *matCellDef="let row">
+                                    <div class="flex items-center justify-end 
gap-x-2">
+                                        <button
+                                            mat-icon-button
+                                            type="button"
+                                            [matMenuTriggerFor]="actionMenu"
+                                            class="h-16 w-16 flex items-center 
justify-center icon global-menu">
+                                            <i class="fa fa-ellipsis-v"></i>
+                                        </button>
+                                        <mat-menu #actionMenu="matMenu" 
xPosition="before">
+                                            <button mat-menu-item 
(click)="editPolicy(row)">Edit</button>
+                                            <button mat-menu-item 
(click)="removePolicy(row)">Remove</button>
+                                        </mat-menu>
+                                    </div>
+                                </td>
+                            </ng-container>
+                            <tr mat-header-row 
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
+                            <tr
+                                mat-row
+                                *matRowDef="let row; let even = even; columns: 
displayedColumns"
+                                [class.even]="even"></tr>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </div>
+    } @else {
+        <div class="flex flex-col gap-y-4 items-center" 
data-qa="error-loading-bucket policies">
+            <i class="fa fa-warning"></i>
+            <div class="text-lg font-semibold">Something went wrong</div>
+            <div class="text-center">Unable to retrieve information. Please 
try again later.</div>
+        </div>
+    }
 </mat-dialog-content>
 
 <mat-dialog-actions align="end">
-    <button mat-flat-button mat-dialog-close>Close</button>
+    <button mat-flat-button (click)="close()">Close</button>
 </mat-dialog-actions>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.spec.ts
new file mode 100644
index 0000000000..19af4ca79b
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.spec.ts
@@ -0,0 +1,326 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+    ManageBucketPoliciesDialogComponent,
+    ManageBucketPoliciesDialogData
+} from './manage-bucket-policies-dialog.component';
+import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from 
'@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { Bucket } from '../../../../../state/buckets';
+import { BehaviorSubject, of, Subject } from 'rxjs';
+import { PolicySelection, BucketPolicyOptionsView } from 
'../../../../../state/policies';
+import { PolicySubject } from '../../../../../service/buckets.service';
+import { AddPolicyToBucketDialogComponent } from 
'../add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component';
+import { EditPolicyDialogComponent } from 
'../edit-policy-dialog/edit-policy-dialog.component';
+import { provideMockStore } from '@ngrx/store/testing';
+import { errorFeatureKey } from '../../../../../state/error';
+
+const bucket: Bucket = {
+    allowBundleRedeploy: false,
+    allowPublicRead: false,
+    createdTimestamp: Date.now(),
+    description: 'Test Bucket',
+    identifier: 'bucket-1',
+    link: { href: '', params: { rel: '' } },
+    name: 'Test Bucket',
+    permissions: { canRead: true, canWrite: true },
+    revision: { version: 1 }
+};
+
+const user1: PolicySubject = {
+    identifier: 'user-1',
+    identity: 'alice',
+    type: 'user',
+    configurable: false
+};
+
+const user2: PolicySubject = {
+    identifier: 'user-2',
+    identity: 'bob',
+    type: 'user',
+    configurable: false
+};
+
+const group1: PolicySubject = {
+    identifier: 'group-1',
+    identity: 'admins',
+    type: 'group',
+    configurable: false
+};
+
+const mockOptions: BucketPolicyOptionsView = {
+    groups: [{ key: 'group-1', label: 'admins', type: 'group', subject: group1 
}],
+    users: [
+        { key: 'user-1', label: 'alice', type: 'user', subject: user1 },
+        { key: 'user-2', label: 'bob', type: 'user', subject: user2 }
+    ],
+    all: [
+        { key: 'group-1', label: 'admins', type: 'group', subject: group1 },
+        { key: 'user-1', label: 'alice', type: 'user', subject: user1 },
+        { key: 'user-2', label: 'bob', type: 'user', subject: user2 }
+    ],
+    lookup: {
+        'group-1': group1,
+        'user-1': user1,
+        'user-2': user2
+    }
+};
+
+const mockSelection: Partial<Record<'read' | 'write' | 'delete', 
PolicySelection>> = {
+    read: {
+        policyId: 'policy-1',
+        revision: { version: 0 },
+        users: [user1],
+        userGroups: [group1]
+    },
+    write: {
+        policyId: 'policy-2',
+        revision: { version: 0 },
+        users: [user1],
+        userGroups: []
+    }
+};
+
+describe('ManageBucketPoliciesDialogComponent', () => {
+    let component: ManageBucketPoliciesDialogComponent;
+    let fixture: ComponentFixture<ManageBucketPoliciesDialogComponent>;
+    let dialogRef: 
jest.Mocked<MatDialogRef<ManageBucketPoliciesDialogComponent>>;
+
+    beforeEach(async () => {
+        const options$ = new 
BehaviorSubject<BucketPolicyOptionsView>(mockOptions);
+        const selection$ = new BehaviorSubject(mockSelection);
+        const loading$ = new BehaviorSubject<boolean>(false);
+        const saving$ = new BehaviorSubject<boolean>(false);
+        const isAddPolicyDisabled$ = new BehaviorSubject<boolean>(false);
+        const isPolicyError$ = new BehaviorSubject<boolean>(false);
+
+        const mockDialogRef = {
+            close: jest.fn()
+        };
+
+        const afterOpenedSubject = new Subject();
+        const afterAllClosedSubject = new Subject();
+        const mockDialog = {
+            open: jest.fn().mockReturnValue({
+                afterClosed: () => of(undefined)
+            }),
+            _getAfterAllClosed: jest.fn(),
+            get afterAllClosed() {
+                return afterAllClosedSubject.asObservable();
+            },
+            _openDialogsAtThisLevel: [],
+            _afterAllClosedAtThisLevel: afterAllClosedSubject,
+            _afterOpenedAtThisLevel: afterOpenedSubject,
+            get afterOpened() {
+                return afterOpenedSubject.asObservable();
+            },
+            openDialogs: []
+        };
+
+        await TestBed.configureTestingModule({
+            imports: [ManageBucketPoliciesDialogComponent, 
NoopAnimationsModule],
+            providers: [
+                provideMockStore({
+                    initialState: {
+                        [errorFeatureKey]: {
+                            bannerErrors: {},
+                            dialogErrors: {}
+                        }
+                    }
+                }),
+                {
+                    provide: MAT_DIALOG_DATA,
+                    useValue: {
+                        bucket,
+                        options$,
+                        selection$,
+                        loading$,
+                        isPolicyError$,
+                        isAddPolicyDisabled$,
+                        saving$
+                    } as ManageBucketPoliciesDialogData
+                },
+                { provide: MatDialogRef, useValue: mockDialogRef },
+                { provide: MatDialog, useValue: mockDialog }
+            ]
+        }).compileComponents();
+
+        fixture = TestBed.createComponent(ManageBucketPoliciesDialogComponent);
+        component = fixture.componentInstance;
+        dialogRef = TestBed.inject(MatDialogRef) as 
jest.Mocked<MatDialogRef<ManageBucketPoliciesDialogComponent>>;
+        fixture.detectChanges();
+    });
+
+    it('should create', () => {
+        expect(component).toBeTruthy();
+    });
+
+    it('should build table rows from policy selections', () => {
+        expect(component.dataSource.data.length).toBe(2);
+
+        const aliceRow = component.dataSource.data.find((r) => r.identity === 
'alice');
+        expect(aliceRow).toBeDefined();
+        expect(aliceRow!.permissions).toBe('read, write');
+        expect(aliceRow!.actions).toEqual(['read', 'write']);
+
+        const adminsRow = component.dataSource.data.find((r) => r.identity === 
'admins');
+        expect(adminsRow).toBeDefined();
+        expect(adminsRow!.permissions).toBe('read');
+        expect(adminsRow!.actions).toEqual(['read']);
+    });
+
+    it('should sort table data correctly', () => {
+        component.sortData({ active: 'identity', direction: 'desc' });
+        expect(component.dataSource.data[0].identity).toBe('alice'); // 
Descending
+    });
+
+    it('should open add policy dialog when addPolicy is called', () => {
+        const dialogSpy = jest.spyOn(component['dialog'], 
'open').mockReturnValue({
+            afterClosed: () => of(undefined)
+        } as any);
+
+        component.addPolicy();
+
+        expect(dialogSpy).toHaveBeenCalledWith(
+            AddPolicyToBucketDialogComponent,
+            expect.objectContaining({
+                autoFocus: false,
+                data: expect.objectContaining({
+                    bucket
+                })
+            })
+        );
+    });
+
+    it('should open edit policy dialog when editPolicy is called', () => {
+        const dialogSpy = jest.spyOn(component['dialog'], 
'open').mockReturnValue({
+            afterClosed: () => of(undefined)
+        } as any);
+
+        const row = component.dataSource.data[0];
+        component.editPolicy(row);
+
+        expect(dialogSpy).toHaveBeenCalledWith(
+            EditPolicyDialogComponent,
+            expect.objectContaining({
+                data: expect.objectContaining({
+                    identity: row.identity,
+                    type: row.type,
+                    currentPermissions: row.actions
+                })
+            })
+        );
+    });
+
+    it('should emit savePolicies when addPolicy dialog returns result', (done) 
=> {
+        const addResult = {
+            userOrGroup: user2,
+            permissions: ['read', 'write']
+        };
+        jest.spyOn(component['dialog'], 'open').mockReturnValue({
+            afterClosed: () => of(addResult)
+        } as any);
+
+        let emitCount = 0;
+        component.savePolicies.subscribe((request) => {
+            expect(request.bucketId).toBe(bucket.identifier);
+            expect(['read', 'write']).toContain(request.action);
+            emitCount++;
+            if (emitCount === 2) {
+                done();
+            }
+        });
+
+        component.addPolicy();
+    });
+
+    it('should emit savePolicies when editPolicy dialog returns result', 
(done) => {
+        // Wait for initialization to complete
+        setTimeout(() => {
+            const row = component.dataSource.data.find((r) => r.identity === 
'alice')!; // alice with ['read', 'write']
+            const editResult = {
+                permissions: ['read'] // Removing 'write'
+            };
+            jest.spyOn(component['dialog'], 'open').mockReturnValue({
+                afterClosed: () => of(editResult)
+            } as any);
+
+            let emitCount = 0;
+            component.savePolicies.subscribe((request) => {
+                expect(request.bucketId).toBe(bucket.identifier);
+                emitCount++;
+                // Should emit once for removing 'write'
+                if (emitCount === 1) {
+                    expect(request.action).toBe('write');
+                    done();
+                }
+            });
+
+            component.editPolicy(row);
+        }, 100);
+    });
+
+    it('should open confirmation dialog and emit savePolicies when 
removePolicy is called', (done) => {
+        const yesSubject = new Subject();
+        jest.spyOn(component['dialog'], 'open').mockReturnValue({
+            componentInstance: {
+                yes: yesSubject.asObservable()
+            },
+            afterClosed: () => of(undefined)
+        } as any);
+
+        const row = component.dataSource.data[0];
+
+        let emitCount = 0;
+        const expectedEmissions = row.actions.length;
+        component.savePolicies.subscribe((request) => {
+            expect(request.bucketId).toBe(bucket.identifier);
+            expect(row.actions).toContain(request.action);
+            emitCount++;
+            if (emitCount === expectedEmissions) {
+                done();
+            }
+        });
+
+        component.removePolicy(row);
+        yesSubject.next(true);
+    });
+
+    it('should format permissions correctly', () => {
+        const permissions = component['formatPermissions'](['write', 'read', 
'delete']);
+        expect(permissions).toBe('delete, read, write'); // Sorted 
alphabetically
+    });
+
+    it('should sort policies by identity', () => {
+        component.sortData({ active: 'identity', direction: 'asc' });
+        const firstRow = component.dataSource.data[0];
+        expect(['admins', 'alice']).toContain(firstRow.identity);
+    });
+
+    it('should sort policies by type', () => {
+        component.sortData({ active: 'type', direction: 'asc' });
+        const data = component.dataSource.data;
+        expect(data[0].type).toBe('group'); // 'group' comes before 'user'
+    });
+
+    it('should close dialog when close is called', () => {
+        component.close();
+        expect(dialogRef.close).toHaveBeenCalled();
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
index 1593ce0ae0..b52736ee38 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
@@ -15,13 +15,56 @@
  * limitations under the License.
  */
 
-import { Component, inject } from '@angular/core';
-import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { Component, DestroyRef, OnInit, inject, Output, EventEmitter } from 
'@angular/core';
+import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from 
'@angular/material/dialog';
 import { MatButtonModule } from '@angular/material/button';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { MatSortModule, Sort } from '@angular/material/sort';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatDialog } from '@angular/material/dialog';
+import { AsyncPipe } from '@angular/common';
+import { Observable } from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { take } from 'rxjs/operators';
+import { NiFiCommon, SMALL_DIALOG, YesNoDialog, MEDIUM_DIALOG, LARGE_DIALOG } 
from '@nifi/shared';
+import {
+    AddPolicyToBucketDialogComponent,
+    AddPolicyResult
+} from '../add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component';
+import { EditPolicyDialogComponent, EditPolicyResult } from 
'../edit-policy-dialog/edit-policy-dialog.component';
 import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+import { BucketPolicyOptionsView, PolicySelection, PolicySection } from 
'apps/nifi-registry/src/app/state/policies';
+import { PolicySubject, PolicyRevision } from 
'apps/nifi-registry/src/app/service/buckets.service';
+import { ContextErrorBanner } from 
'../../../../../ui/common/context-error-banner/context-error-banner.component';
+import { ErrorContextKey } from '../../../../../state/error';
+
+export interface PolicyTableRow {
+    identity: string;
+    type: 'user' | 'group';
+    permissions: string;
+    identifier: string;
+    actions: PolicySection[];
+}
+
+export interface SaveBucketPoliciesRequest {
+    bucketId: string;
+    action: PolicySection;
+    policyId?: string;
+    revision?: PolicyRevision;
+    users: PolicySubject[];
+    userGroups: PolicySubject[];
+    isLastInBatch?: boolean;
+}
 
 export interface ManageBucketPoliciesDialogData {
     bucket: Bucket;
+    options$: Observable<BucketPolicyOptionsView>;
+    selection$: Observable<Partial<Record<PolicySection, PolicySelection>>>;
+    loading$: Observable<boolean>;
+    saving$: Observable<boolean>;
+    isAddPolicyDisabled$: Observable<boolean>;
+    isPolicyError$: Observable<boolean>;
 }
 
 @Component({
@@ -29,8 +72,305 @@ export interface ManageBucketPoliciesDialogData {
     templateUrl: './manage-bucket-policies-dialog.component.html',
     styleUrl: './manage-bucket-policies-dialog.component.scss',
     standalone: true,
-    imports: [MatDialogModule, MatButtonModule]
+    imports: [
+        MatDialogModule,
+        MatButtonModule,
+        MatProgressSpinnerModule,
+        MatTableModule,
+        MatSortModule,
+        MatMenuModule,
+        AsyncPipe,
+        ContextErrorBanner
+    ]
 })
-export class ManageBucketPoliciesDialogComponent {
+export class ManageBucketPoliciesDialogComponent implements OnInit {
     protected data = inject<ManageBucketPoliciesDialogData>(MAT_DIALOG_DATA);
+    private dialogRef = 
inject(MatDialogRef<ManageBucketPoliciesDialogComponent>);
+    private destroyRef = inject(DestroyRef);
+    private nifiCommon = inject(NiFiCommon);
+    private dialog = inject(MatDialog);
+
+    // Get observables from dialog data
+    options$ = this.data.options$;
+    selection$ = this.data.selection$;
+    loading$ = this.data.loading$;
+    saving$ = this.data.saving$;
+    isPolicyError$ = this.data.isPolicyError$;
+    isAddPolicyDisabled$ = this.data.isAddPolicyDisabled$;
+
+    @Output() savePolicies = new EventEmitter<SaveBucketPoliciesRequest>();
+
+    private latestOptions: BucketPolicyOptionsView | null = null;
+    private latestSelection: Partial<Record<PolicySection, PolicySelection>> = 
{};
+
+    dataSource: MatTableDataSource<PolicyTableRow> = new 
MatTableDataSource<PolicyTableRow>();
+    displayedColumns: string[] = ['type', 'identity', 'permissions', 
'actions'];
+    sort: Sort = {
+        active: 'identity',
+        direction: 'asc'
+    };
+
+    ngOnInit(): void {
+        // Subscribe to options$ to keep latest options
+        
this.options$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((options) => {
+            this.latestOptions = options;
+        });
+
+        // Subscribe to selection$ to build table data
+        
this.selection$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((selectionMap)
 => {
+            this.latestSelection = selectionMap || {};
+            const rows = this.buildTableRows(selectionMap || {});
+            this.dataSource.data = this.sortPolicies(rows, this.sort);
+        });
+    }
+
+    private buildTableRows(selectionMap: Partial<Record<PolicySection, 
PolicySelection>>): PolicyTableRow[] {
+        const rowMap = new Map<string, PolicyTableRow>();
+
+        // Iterate through each policy type (read, write, delete)
+        (['read', 'write', 'delete'] as PolicySection[]).forEach((section) => {
+            const selection = selectionMap[section];
+            if (!selection) return;
+
+            // Process users
+            selection.users.forEach((user) => {
+                const key = `user-${user.identifier}`;
+                const existing = rowMap.get(key);
+                if (existing) {
+                    existing.actions.push(section);
+                    existing.permissions = 
this.formatPermissions(existing.actions);
+                } else {
+                    rowMap.set(key, {
+                        identity: user.identity,
+                        type: 'user',
+                        permissions: section,
+                        identifier: user.identifier,
+                        actions: [section]
+                    });
+                }
+            });
+
+            // Process groups
+            selection.userGroups.forEach((group) => {
+                const key = `group-${group.identifier}`;
+                const existing = rowMap.get(key);
+                if (existing) {
+                    existing.actions.push(section);
+                    existing.permissions = 
this.formatPermissions(existing.actions);
+                } else {
+                    rowMap.set(key, {
+                        identity: group.identity,
+                        type: 'group',
+                        permissions: section,
+                        identifier: group.identifier,
+                        actions: [section]
+                    });
+                }
+            });
+        });
+
+        return Array.from(rowMap.values());
+    }
+
+    private formatPermissions(actions: PolicySection[]): string {
+        return actions.sort().join(', ');
+    }
+
+    sortData(sort: Sort) {
+        this.sort = sort;
+        this.dataSource.data = this.sortPolicies(this.dataSource.data, sort);
+    }
+
+    sortPolicies(data: PolicyTableRow[], sort: Sort): PolicyTableRow[] {
+        if (!data) {
+            return [];
+        }
+        return data.slice().sort((a, b) => {
+            const isAsc = sort.direction === 'asc';
+            let retVal = 0;
+            switch (sort.active) {
+                case 'identity':
+                    retVal = this.nifiCommon.compareString(a.identity, 
b.identity);
+                    break;
+                case 'type':
+                    retVal = this.nifiCommon.compareString(a.type, b.type);
+                    break;
+                case 'permissions':
+                    retVal = this.nifiCommon.compareString(a.permissions, 
b.permissions);
+                    break;
+            }
+            return retVal * (isAsc ? 1 : -1);
+        });
+    }
+
+    addPolicy(): void {
+        if (!this.latestOptions) {
+            return;
+        }
+
+        // Get list of existing users and groups across all policy types
+        const existingUsers = new Set<string>();
+        const existingGroups = new Set<string>();
+
+        Object.values(this.latestSelection).forEach((selection) => {
+            if (selection) {
+                selection.users.forEach((user) => 
existingUsers.add(user.identity));
+                selection.userGroups.forEach((group) => 
existingGroups.add(group.identity));
+            }
+        });
+
+        const dialogRef = this.dialog.open(AddPolicyToBucketDialogComponent, {
+            ...LARGE_DIALOG,
+            autoFocus: false,
+            data: {
+                bucket: this.data.bucket,
+                existingUsers: Array.from(existingUsers),
+                existingGroups: Array.from(existingGroups),
+                availableUsers: this.latestOptions.users.map((u) => u.subject),
+                availableGroups: this.latestOptions.groups.map((g) => 
g.subject)
+            }
+        });
+
+        dialogRef.afterClosed().subscribe((result: AddPolicyResult | 
undefined) => {
+            if (result) {
+                // Emit save requests
+                result.permissions.forEach((action, index) => {
+                    const currentSelection = this.latestSelection[action];
+
+                    // Create copies of arrays (state arrays are immutable)
+                    const users = [...(currentSelection?.users || [])];
+                    const userGroups = [...(currentSelection?.userGroups || 
[])];
+
+                    if (result.userOrGroup.type === 'user') {
+                        users.push(result.userOrGroup);
+                    } else {
+                        userGroups.push(result.userOrGroup);
+                    }
+
+                    const isLast = index === result.permissions.length - 1;
+
+                    this.savePolicies.emit({
+                        bucketId: this.data.bucket.identifier,
+                        action,
+                        policyId: currentSelection?.policyId,
+                        revision: currentSelection?.revision,
+                        users,
+                        userGroups,
+                        isLastInBatch: isLast
+                    });
+                });
+            }
+        });
+    }
+
+    editPolicy(row: PolicyTableRow): void {
+        const dialogRef = this.dialog.open(EditPolicyDialogComponent, {
+            ...MEDIUM_DIALOG,
+            data: {
+                identity: row.identity,
+                type: row.type,
+                currentPermissions: row.actions
+            }
+        });
+
+        dialogRef.afterClosed().subscribe((result: EditPolicyResult | 
undefined) => {
+            if (result) {
+                const previousPermissions = new Set(row.actions);
+                const newPermissions = new Set(result.permissions);
+
+                // Determine which permissions to add and which to remove
+                const toAdd: PolicySection[] = result.permissions.filter((p) 
=> !previousPermissions.has(p));
+                const toRemove: PolicySection[] = row.actions.filter((p) => 
!newPermissions.has(p));
+
+                const allActions = [...toAdd, ...toRemove];
+
+                // Process all changes
+                allActions.forEach((action, index) => {
+                    const currentSelection = this.latestSelection[action];
+                    if (!currentSelection) return;
+
+                    // Create copies of arrays
+                    let users = [...currentSelection.users];
+                    let userGroups = [...currentSelection.userGroups];
+
+                    if (newPermissions.has(action)) {
+                        // Add to this policy
+                        const subject = {
+                            identifier: row.identifier,
+                            identity: row.identity,
+                            type: row.type
+                        };
+
+                        if (row.type === 'user') {
+                            // Check if not already present
+                            if (!users.find((u) => u.identifier === 
row.identifier)) {
+                                users.push(subject);
+                            }
+                        } else {
+                            // Check if not already present
+                            if (!userGroups.find((g) => g.identifier === 
row.identifier)) {
+                                userGroups.push(subject);
+                            }
+                        }
+                    } else {
+                        // Remove from this policy
+                        users = users.filter((u) => u.identifier !== 
row.identifier);
+                        userGroups = userGroups.filter((g) => g.identifier !== 
row.identifier);
+                    }
+
+                    const isLast = index === allActions.length - 1;
+
+                    this.savePolicies.emit({
+                        bucketId: this.data.bucket.identifier,
+                        action,
+                        policyId: currentSelection.policyId,
+                        revision: currentSelection.revision,
+                        users,
+                        userGroups,
+                        isLastInBatch: isLast
+                    });
+                });
+            }
+        });
+    }
+
+    removePolicy(row: PolicyTableRow): void {
+        const dialogRef = this.dialog.open(YesNoDialog, {
+            ...SMALL_DIALOG,
+            data: {
+                title: 'Delete Policy',
+                message: `All permissions granted by this policy will be 
removed for ${row.identity}.`
+            }
+        });
+
+        dialogRef.componentInstance.yes.pipe(take(1)).subscribe(() => {
+            // Remove this user/group from each policy they have
+            row.actions.forEach((action, index) => {
+                const currentSelection = this.latestSelection[action];
+                if (!currentSelection) return;
+
+                // Create copies and filter out the removed user/group
+                const users = [...currentSelection.users].filter((u) => 
u.identifier !== row.identifier);
+                const userGroups = [...currentSelection.userGroups].filter((g) 
=> g.identifier !== row.identifier);
+
+                const isLast = index === row.actions.length - 1;
+
+                this.savePolicies.emit({
+                    bucketId: this.data.bucket.identifier,
+                    action,
+                    policyId: currentSelection.policyId,
+                    revision: currentSelection.revision,
+                    users,
+                    userGroups,
+                    isLastInBatch: isLast
+                });
+            });
+        });
+    }
+
+    close(): void {
+        this.dialogRef.close();
+    }
+
+    protected readonly ErrorContextKey = ErrorContextKey;
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
index d837ed4ad6..f3fde4ea38 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
@@ -27,8 +27,9 @@ import { MatFormFieldModule } from 
'@angular/material/form-field';
 import { MatInputModule } from '@angular/material/input';
 import { MatButtonModule } from '@angular/material/button';
 import { RouterTestingModule } from '@angular/router/testing';
-import { selectLoginFailure, selectLoginPending } from 
'../../state/access/access.selectors';
-import { selectLogoutSupported } from 
'../../../../state/current-user/current-user.selectors';
+import { selectLoginFailure } from '../../state/access/access.selectors';
+import { initialState as initialCurrentUserState } from 
'../../../../state/current-user/current-user.reducer';
+import { currentUserFeatureKey } from '../../../../state/current-user';
 
 describe('LoginFormComponent', () => {
     let component: LoginFormComponent;
@@ -36,8 +37,6 @@ describe('LoginFormComponent', () => {
     let store: MockStore<NiFiRegistryState>;
     let dispatchSpy: jest.SpyInstance;
     let loginFailureSelector: any;
-    let loginPendingSelector: any;
-    let logoutSupportedSelector: any;
 
     beforeEach(async () => {
         await TestBed.configureTestingModule({
@@ -49,15 +48,20 @@ describe('LoginFormComponent', () => {
                 MatButtonModule,
                 RouterTestingModule
             ],
-            providers: [provideMockStore(), provideHttpClientTesting()]
+            providers: [
+                provideMockStore({
+                    initialState: {
+                        [currentUserFeatureKey]: initialCurrentUserState
+                    }
+                }),
+                provideHttpClientTesting()
+            ]
         }).compileComponents();
 
         fixture = TestBed.createComponent(LoginFormComponent);
         component = fixture.componentInstance;
         store = TestBed.inject(MockStore);
         loginFailureSelector = store.overrideSelector(selectLoginFailure, 
null);
-        loginPendingSelector = store.overrideSelector(selectLoginPending, 
false);
-        logoutSupportedSelector = 
store.overrideSelector(selectLogoutSupported, false);
         dispatchSpy = jest.spyOn(store, 'dispatch');
         fixture.detectChanges();
     });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
index 18653f5593..deb75b342b 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
@@ -33,7 +33,12 @@
                 [totalCount]="dataSource.data.length"
                 (filterChanged)="applyFilter($event)"></droplet-table-filter>
             <div class="flex justify-end">
-                <button mat-icon-button class="primary-icon-button" 
(click)="openImportNewDropletDialog()">
+                <button
+                    mat-icon-button
+                    class="primary-icon-button"
+                    [disabled]="!canImportNewDroplet"
+                    (click)="openImportNewDropletDialog()"
+                    matTooltip="Import new resource">
                     <i class="fa fa-plus"></i>
                 </button>
             </div>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
index fe40532b37..bfb1620f90 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
@@ -155,6 +155,14 @@ export class ResourcesComponent implements OnInit {
         });
     }
 
+    filterWritableBuckets(buckets: Bucket[]): Bucket[] {
+        return buckets.filter((bucket) => bucket.permissions.canWrite);
+    }
+
+    get canImportNewDroplet(): boolean {
+        return this.buckets.length > 0 && 
this.filterWritableBuckets(this.buckets).length > 0;
+    }
+
     openImportNewDropletDialog() {
         this.store.dispatch(openImportNewDropletDialog({ request: { buckets: 
this.buckets } }));
     }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
index f821a0e2ac..1d11f88abc 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
@@ -29,6 +29,7 @@ import { MatSortModule } from '@angular/material/sort';
 import { MatMenuModule } from '@angular/material/menu';
 import { DropletTableFilterComponent } from 
'./ui/droplet-table-filter/droplet-table-filter.component';
 import { MatButtonModule } from '@angular/material/button';
+import { MatTooltipModule } from '@angular/material/tooltip';
 import { DropletTableComponent } from 
'./ui/droplet-table/droplet-table.component';
 import { ContextErrorBanner } from 
'../../../ui/common/context-error-banner/context-error-banner.component';
 import { HeaderComponent } from '../../../ui/header/header.component';
@@ -41,15 +42,15 @@ import { HeaderComponent } from 
'../../../ui/header/header.component';
         MatTableModule,
         MatSortModule,
         MatMenuModule,
-        DropletTableFilterComponent,
-        MatButtonModule,
         MatButtonModule,
-        DropletTableComponent,
-        ContextErrorBanner,
+        MatTooltipModule,
         ResourcesRoutingModule,
         StoreModule.forFeature(resourcesFeatureKey, reducers),
         EffectsModule.forFeature([DropletsEffects, BucketsEffects]),
-        HeaderComponent
+        HeaderComponent,
+        DropletTableFilterComponent,
+        DropletTableComponent,
+        ContextErrorBanner
     ]
 })
 export class ResourcesModule {}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
index 975a858229..c861484b08 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
@@ -102,15 +102,24 @@
                                         <i class="fa fa-list primary-color 
mr-2"></i>
                                         See Versions
                                     </button>
-                                    <button mat-menu-item 
(click)="openImportNewDropletVersionDialog(item)">
+                                    <button
+                                        mat-menu-item
+                                        [disabled]="!canImportNewVersion(item)"
+                                        
(click)="openImportNewDropletVersionDialog(item)">
                                         <i class="fa fa-upload primary-color 
mr-2"></i>
                                         Import new version
                                     </button>
-                                    <button mat-menu-item 
(click)="openExportDropletVersionDialog(item)">
+                                    <button
+                                        mat-menu-item
+                                        [disabled]="!canExportVersion(item)"
+                                        
(click)="openExportDropletVersionDialog(item)">
                                         <i class="fa fa-download primary-color 
mr-2"></i>
                                         Export version
                                     </button>
-                                    <button mat-menu-item 
(click)="openDeleteDialog(item)">
+                                    <button
+                                        mat-menu-item
+                                        [disabled]="!canDeleteResource(item)"
+                                        (click)="openDeleteDialog(item)">
                                         <i class="fa fa-trash primary-color 
mr-2"></i>
                                         Delete resource
                                     </button>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
index 20f39c7292..f1684a872e 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
@@ -33,7 +33,7 @@ import {
 
 @Component({
     selector: 'droplet-table',
-    imports: [MatTableModule, MatSortModule, MatMenuModule, MatButtonModule, 
MatButtonModule],
+    imports: [MatTableModule, MatSortModule, MatMenuModule, MatButtonModule],
     templateUrl: './droplet-table.component.html',
     styleUrl: './droplet-table.component.scss'
 })
@@ -131,4 +131,16 @@ export class DropletTableComponent implements OnInit {
     openDropletVersionsDialog(droplet: Droplet) {
         this.store.dispatch(openDropletVersionsDialog({ request: { droplet } 
}));
     }
+
+    canImportNewVersion(droplet: Droplet): boolean {
+        return droplet.permissions.canWrite;
+    }
+
+    canExportVersion(droplet: Droplet): boolean {
+        return droplet.permissions.canRead;
+    }
+
+    canDeleteResource(droplet: Droplet): boolean {
+        return droplet.permissions.canDelete;
+    }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
index cea12323b6..a795251e57 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
@@ -16,10 +16,49 @@
  */
 
 import { Injectable, inject } from '@angular/core';
-import { Observable } from 'rxjs';
-import { HttpClient } from '@angular/common/http';
+import { forkJoin, Observable, of, throwError } from 'rxjs';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { Bucket } from '../state/buckets';
 import { CreateBucketRequest, DeleteBucketRequest } from 
'../state/buckets/buckets.actions';
+import { catchError } from 'rxjs/operators';
+
+export type PolicyAction = 'read' | 'write' | 'delete';
+
+export interface PolicySubject {
+    identifier: string;
+    identity: string;
+    type: 'user' | 'group';
+    [key: string]: unknown;
+}
+
+export interface PolicyRevision {
+    version: number;
+    clientId?: string;
+}
+
+export interface Policy {
+    identifier: string;
+    action: PolicyAction;
+    resource: string;
+    users: PolicySubject[];
+    userGroups: PolicySubject[];
+    revision: PolicyRevision;
+    [key: string]: unknown;
+}
+
+export interface SaveBucketPolicyRequest {
+    bucketId: string;
+    action: PolicyAction;
+    policyId?: string;
+    users: PolicySubject[];
+    userGroups: PolicySubject[];
+    revision?: PolicyRevision;
+}
+
+export interface BucketPolicyTenants {
+    users: PolicySubject[];
+    userGroups: PolicySubject[];
+}
 
 @Injectable({ providedIn: 'root' })
 export class BucketsService {
@@ -96,4 +135,78 @@ export class BucketsService {
             
`${BucketsService.API}/buckets/${request.bucket.identifier}?version=${request.version}`
         );
     }
+
+    getPolicies(): Observable<Policy[]> {
+        // const mockError: HttpErrorResponse = new HttpErrorResponse({
+        //     status: 404,
+        //     statusText: 'Bad Gateway',
+        //     url: `${BucketsService.API}/policies`,
+        //     error: {
+        //         message: 'Mock error: unable to get policies.',
+        //         timestamp: new Date().toISOString()
+        //     }
+        // });
+        // return throwError(() => mockError);
+
+        return this.httpClient.get<Policy[]>(`${BucketsService.API}/policies`);
+    }
+
+    getBucketPolicyTenants(): Observable<BucketPolicyTenants> {
+        // const mockError: HttpErrorResponse = new HttpErrorResponse({
+        //     status: 404,
+        //     statusText: 'Bad Gateway',
+        //     url: `${BucketsService.API}/tenants/users`,
+        //     error: {
+        //         message: 'Mock error: unable to get bucket policy tenents.',
+        //         timestamp: new Date().toISOString()
+        //     }
+        // });
+        // return throwError(() => mockError);
+
+        return forkJoin({
+            users: this.httpClient
+                .get<PolicySubject[]>(`${BucketsService.API}/tenants/users`)
+                .pipe(catchError((error: HttpErrorResponse) => 
this.handleTenantError(error))),
+            userGroups: this.httpClient
+                
.get<PolicySubject[]>(`${BucketsService.API}/tenants/user-groups`)
+                .pipe(catchError((error: HttpErrorResponse) => 
this.handleTenantError(error)))
+        });
+    }
+
+    saveBucketPolicy(request: SaveBucketPolicyRequest): Observable<Policy> {
+        const resource = this.buildResourcePath(request.bucketId);
+        const payload = this.buildPolicyPayload(request, resource);
+
+        if (request.policyId) {
+            return this.httpClient
+                
.put<Policy>(`${BucketsService.API}/policies/${request.policyId}`, payload)
+                .pipe(catchError((error: HttpErrorResponse) => throwError(() 
=> error)));
+        }
+
+        return this.httpClient
+            .post<Policy>(`${BucketsService.API}/policies`, payload)
+            .pipe(catchError((error: HttpErrorResponse) => throwError(() => 
error)));
+    }
+
+    private buildPolicyPayload(request: SaveBucketPolicyRequest, resource: 
string) {
+        return {
+            identifier: request.policyId ?? null,
+            action: request.action,
+            resource,
+            users: request.users,
+            userGroups: request.userGroups,
+            revision: request.policyId ? (request.revision ?? { version: 0 }) 
: { version: 0 }
+        };
+    }
+
+    private buildResourcePath(bucketId: string): string {
+        return `/buckets/${bucketId}`;
+    }
+
+    private handleTenantError(error: HttpErrorResponse): 
Observable<PolicySubject[]> {
+        if (error.status === 404) {
+            return of([]);
+        }
+        return throwError(() => error);
+    }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
index c612dc3f3e..97d14213aa 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
@@ -76,5 +76,3 @@ export const openManageBucketPoliciesDialog = createAction(
     '[Buckets] Open Manage Bucket Policies Dialog',
     props<{ request: { bucket: Bucket } }>()
 );
-
-export const bucketNoOp = createAction('[Buckets] No Op');
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
index 107b1bef37..55dc950b00 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
@@ -18,12 +18,13 @@
 import { TestBed } from '@angular/core/testing';
 import { provideMockActions } from '@ngrx/effects/testing';
 import { Action } from '@ngrx/store';
-import { Observable, of, throwError } from 'rxjs';
+import { Observable, of, throwError, Subject } from 'rxjs';
 import { BucketsEffects } from './buckets.effects';
 import { BucketsService } from '../../service/buckets.service';
 import { ErrorHelper } from '../../service/error-helper.service';
 import { MatDialog } from '@angular/material/dialog';
 import * as BucketsActions from './buckets.actions';
+import * as PoliciesActions from '../policies/policies.actions';
 import * as ErrorActions from '../error/error.actions';
 import { HttpErrorResponse } from '@angular/common/http';
 import { ErrorContextKey } from '../error';
@@ -331,21 +332,52 @@ describe('BucketsEffects', () => {
     });
 
     describe('openManageBucketPoliciesDialog$', () => {
-        it('should open manage bucket policies dialog', (done) => {
+        it('should dispatch load actions and open dialog when both succeed', 
(done) => {
             const bucket = createBucket();
+            const mockDialogRef = {
+                componentInstance: {
+                    savePolicies: {
+                        pipe: jest.fn().mockReturnValue({ subscribe: jest.fn() 
})
+                    }
+                },
+                afterClosed: jest.fn().mockReturnValue(of(undefined))
+            };
 
-            actions$ = of(BucketsActions.openManageBucketPoliciesDialog({ 
request: { bucket } }));
+            dialog.open.mockReturnValue(mockDialogRef as any);
+
+            // Use a Subject to control when actions are emitted
+            const actionsSubject = new Subject<Action>();
+            actions$ = actionsSubject.asObservable();
 
+            // Subscribe to the effect
             effects.openManageBucketPoliciesDialog$.subscribe(() => {
                 expect(dialog.open).toHaveBeenCalledWith(
                     ManageBucketPoliciesDialogComponent,
                     expect.objectContaining({
                         autoFocus: false,
-                        data: { bucket }
+                        data: expect.objectContaining({
+                            bucket
+                        })
                     })
                 );
+                expect(store.dispatch).toHaveBeenCalledTimes(2); // 
loadPolicyTenants + loadPolicies
                 done();
             });
+
+            // Emit the initial action to trigger the effect
+            
actionsSubject.next(BucketsActions.openManageBucketPoliciesDialog({ request: { 
bucket } }));
+
+            // Allow the effect to set up listeners, then emit success actions
+            setTimeout(() => {
+                actionsSubject.next(
+                    PoliciesActions.loadPolicyTenantsSuccess({ response: { 
users: [], userGroups: [] } })
+                );
+                actionsSubject.next(
+                    PoliciesActions.loadPoliciesSuccess({
+                        response: { bucketId: bucket.identifier, policies: [] }
+                    })
+                );
+            }, 10);
         });
     });
 
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
index 39bedcb889..8787fb6539 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
@@ -19,9 +19,10 @@ import { inject, Injectable } from '@angular/core';
 import { HttpErrorResponse } from '@angular/common/http';
 import { MatDialog } from '@angular/material/dialog';
 import { Actions, createEffect, ofType } from '@ngrx/effects';
-import { from, of, take } from 'rxjs';
+import { from, of, take, takeUntil, forkJoin, race } from 'rxjs';
 import { catchError, map, switchMap, tap } from 'rxjs/operators';
 import * as BucketsActions from './buckets.actions';
+import { deleteBucket } from './buckets.actions';
 import { BucketsService } from '../../service/buckets.service';
 import { ErrorHelper } from '../../service/error-helper.service';
 import { ErrorContextKey } from '../error';
@@ -29,10 +30,17 @@ import * as ErrorActions from '../error/error.actions';
 import { CreateBucketDialogComponent } from 
'../../pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component';
 import { EditBucketDialogComponent } from 
'../../pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component';
 import { ManageBucketPoliciesDialogComponent } from 
'../../pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component';
-import { LARGE_DIALOG, MEDIUM_DIALOG, SMALL_DIALOG, YesNoDialog } from 
'@nifi/shared';
-import { deleteBucket } from './buckets.actions';
+import { MEDIUM_DIALOG, XL_DIALOG, YesNoDialog } from '@nifi/shared';
 import { Store } from '@ngrx/store';
-import { NiFiState } from '../../../../../nifi/src/app/state';
+import {
+    selectPoliciesLoading,
+    selectPoliciesSaving,
+    selectPolicyOptions,
+    selectPolicySelection
+} from '../policies/policies.selectors';
+import { selectCurrentUser } from '../current-user/current-user.selectors';
+import * as PoliciesActions from '../policies/policies.actions';
+import { selectBannerErrors } from '../error/error.selectors';
 
 @Injectable()
 export class BucketsEffects {
@@ -40,7 +48,7 @@ export class BucketsEffects {
     private errorHelper = inject(ErrorHelper);
     private dialog = inject(MatDialog);
     private actions$ = inject(Actions);
-    private store = inject<Store<NiFiState>>(Store);
+    private store = inject(Store);
 
     loadBuckets$ = createEffect(() =>
         this.actions$.pipe(
@@ -151,7 +159,7 @@ export class BucketsEffects {
                 ofType(BucketsActions.openDeleteBucketDialog),
                 tap(({ request }) => {
                     const dialogRef = this.dialog.open(YesNoDialog, {
-                        ...SMALL_DIALOG,
+                        ...MEDIUM_DIALOG,
                         data: {
                             title: 'Delete Bucket',
                             message: `All items stored in this bucket will be 
deleted as well.`
@@ -202,12 +210,97 @@ export class BucketsEffects {
         () =>
             this.actions$.pipe(
                 ofType(BucketsActions.openManageBucketPoliciesDialog),
-                tap(({ request }) => {
-                    this.dialog.open(ManageBucketPoliciesDialogComponent, {
-                        ...LARGE_DIALOG,
-                        autoFocus: false,
-                        data: { bucket: request.bucket }
-                    });
+                switchMap(({ request }) => {
+                    // Dispatch both load actions
+                    this.store.dispatch(
+                        PoliciesActions.loadPolicyTenants({ request: { 
context: ErrorContextKey.GLOBAL } })
+                    );
+                    this.store.dispatch(
+                        PoliciesActions.loadPolicies({
+                            request: { bucketId: request.bucket.identifier, 
context: ErrorContextKey.GLOBAL }
+                        })
+                    );
+
+                    // Wait for both success actions or either failure action
+                    const tenantsSuccess$ = this.actions$.pipe(
+                        ofType(PoliciesActions.loadPolicyTenantsSuccess),
+                        take(1)
+                    );
+
+                    const policiesSuccess$ = 
this.actions$.pipe(ofType(PoliciesActions.loadPoliciesSuccess), take(1));
+
+                    const anyFailure$ = this.actions$.pipe(
+                        ofType(PoliciesActions.loadPolicyTenantsFailure, 
PoliciesActions.loadPoliciesFailure),
+                        take(1),
+                        map(() => null) // Return null to indicate failure
+                    );
+
+                    // Race between both succeeding or any failing
+                    return race(forkJoin([tenantsSuccess$, policiesSuccess$]), 
anyFailure$).pipe(
+                        tap((result) => {
+                            // Only open dialog if both succeeded (result is 
not null)
+                            if (result) {
+                                const dialogRef = 
this.dialog.open(ManageBucketPoliciesDialogComponent, {
+                                    ...XL_DIALOG,
+                                    autoFocus: false,
+                                    data: {
+                                        bucket: request.bucket,
+                                        options$: 
this.store.select(selectPolicyOptions),
+                                        selection$: 
this.store.select(selectPolicySelection),
+                                        loading$: 
this.store.select(selectPoliciesLoading),
+                                        saving$: 
this.store.select(selectPoliciesSaving),
+                                        isPolicyError$: this.store
+                                            
.select(selectBannerErrors(ErrorContextKey.MANAGE_ACCESS))
+                                            .pipe(
+                                                map((bannerErrors) => {
+                                                    if (bannerErrors.length > 
0) {
+                                                        return true;
+                                                    }
+                                                    return false;
+                                                })
+                                            ),
+                                        isAddPolicyDisabled$: 
this.store.select(selectCurrentUser).pipe(
+                                            map((currentUser) => {
+                                                // Disable if anonymous
+                                                if (currentUser.anonymous) {
+                                                    return true;
+                                                }
+                                                // Disable if user can't write 
policies
+                                                if 
(!currentUser.resourcePermissions.policies.canWrite) {
+                                                    return true;
+                                                }
+                                                // Disable if user can't read 
tenants
+                                                if 
(!currentUser.resourcePermissions.tenants.canRead) {
+                                                    return true;
+                                                }
+                                                return false;
+                                            })
+                                        )
+                                    }
+                                });
+
+                                // Subscribe to output and handle multiple 
emissions until dialog closes
+                                dialogRef.componentInstance.savePolicies
+                                    .pipe(takeUntil(dialogRef.afterClosed()))
+                                    .subscribe((saveRequest) => {
+                                        this.store.dispatch(
+                                            PoliciesActions.saveBucketPolicy({
+                                                request: {
+                                                    bucketId: 
saveRequest.bucketId,
+                                                    action: saveRequest.action,
+                                                    policyId: 
saveRequest.policyId,
+                                                    revision: 
saveRequest.revision,
+                                                    users: saveRequest.users,
+                                                    userGroups: 
saveRequest.userGroups,
+                                                    isLastInBatch: 
saveRequest.isLastInBatch
+                                                }
+                                            })
+                                        );
+                                    });
+                            }
+                            // If result is null, errors are already handled 
by the individual effects
+                        })
+                    );
                 })
             ),
         { dispatch: false }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
index 9ee3476afb..4a63212f05 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
@@ -17,7 +17,6 @@
 
 import { createFeatureSelector, createSelector } from '@ngrx/store';
 import { bucketsFeatureKey, BucketsState } from './index';
-
 import { resourcesFeatureKey, ResourcesState } from '..';
 import { selectCurrentRoute } from '@nifi/shared';
 
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
index 317adf1392..36b848beff 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
@@ -26,7 +26,6 @@ import { RegistryAuthService } from 
'../../service/registry-auth.service';
 import * as CurrentUserActions from './current-user.actions';
 import { HttpErrorResponse } from '@angular/common/http';
 import { Store } from '@ngrx/store';
-import { selectLoginFailure } from 
'../../pages/login/state/access/access.selectors';
 import { resetLoginFailure } from 
'../../pages/login/state/access/access.actions';
 import * as ErrorActions from '../error/error.actions';
 
@@ -36,7 +35,6 @@ describe('CurrentUserEffects', () => {
     let authService: jest.Mocked<RegistryAuthService>;
     let errorHelper: jest.Mocked<ErrorHelper>;
     let store: Store<any>;
-    let dispatchSpy: jest.SpyInstance;
 
     beforeEach(() => {
         const registryApiServiceMock = {
@@ -58,7 +56,6 @@ describe('CurrentUserEffects', () => {
             dispatch: jest.fn(),
             select: jest.fn().mockReturnValue(of(null))
         } as unknown as Store<any>;
-        dispatchSpy = jest.spyOn(store as any, 'dispatch');
 
         TestBed.configureTestingModule({
             providers: [
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
index 8e270e0249..0088db74e4 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
@@ -27,6 +27,7 @@ export enum ErrorContextKey {
     CREATE_DROPLET = 'create droplet',
     IMPORT_DROPLET_VERSION = 'import droplet version',
     CREATE_BUCKET = 'create bucket',
+    MANAGE_ACCESS = 'manage access',
     UPDATE_BUCKET = 'update bucket',
     GLOBAL = 'global'
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
index 347581c1da..6bb7fb18ae 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
@@ -21,6 +21,8 @@ import { dropletsFeatureKey, DropletsState } from 
'./droplets';
 import { dropletsReducer } from './droplets/droplets.reducer';
 import { bucketsFeatureKey, BucketsState } from './buckets';
 import { bucketsReducer } from './buckets/buckets.reducer';
+import { policiesFeatureKey, PoliciesState } from './policies';
+import { policiesReducer } from './policies/policies.reducer';
 import { errorReducer } from './error/error.reducer';
 import { errorFeatureKey, ErrorState } from './error';
 import { aboutFeatureKey, AboutState } from './about';
@@ -52,12 +54,14 @@ export interface Permissions {
 export interface ResourcesState {
     [dropletsFeatureKey]: DropletsState;
     [bucketsFeatureKey]: BucketsState;
+    [policiesFeatureKey]: PoliciesState;
 }
 
 export function reducers(state: ResourcesState | undefined, action: Action) {
     return combineReducers({
         [dropletsFeatureKey]: dropletsReducer,
-        [bucketsFeatureKey]: bucketsReducer
+        [bucketsFeatureKey]: bucketsReducer,
+        [policiesFeatureKey]: policiesReducer
     })(state, action);
 }
 
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/index.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/index.ts
new file mode 100644
index 0000000000..5ce7492335
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/index.ts
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { PolicySubject } from '../../service/buckets.service';
+
+export const policiesFeatureKey = 'policies';
+
+export type PolicySection = 'read' | 'write' | 'delete';
+
+export interface PolicySelection {
+    policyId?: string;
+    revision?: { version: number; clientId?: string };
+    users: PolicySubject[];
+    userGroups: PolicySubject[];
+}
+
+export interface BucketPolicyOptionsView {
+    groups: { key: string; label: string; type: 'group'; subject: 
PolicySubject }[];
+    users: { key: string; label: string; type: 'user'; subject: PolicySubject 
}[];
+    all: { key: string; label: string; type: 'user' | 'group'; subject: 
PolicySubject }[];
+    lookup: Record<string, PolicySubject>;
+}
+
+export interface PoliciesState {
+    bucketId: string | null;
+    tenants: {
+        users: PolicySubject[];
+        userGroups: PolicySubject[];
+    };
+    loadingPolicies: boolean;
+    loadingTenants: boolean;
+    saving: boolean;
+    error: string | null;
+    policySelection: Partial<Record<PolicySection, PolicySelection>>;
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.actions.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.actions.ts
new file mode 100644
index 0000000000..f770b11402
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.actions.ts
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createAction, props } from '@ngrx/store';
+import { PolicySection } from '.';
+import { Policy, PolicyRevision, PolicySubject } from 
'../../service/buckets.service';
+import { ErrorContextKey } from '../error';
+
+export const loadPolicies = createAction(
+    '[Policies] Load Policies',
+    props<{ request: { bucketId: string; context: ErrorContextKey } }>()
+);
+
+export const loadPoliciesSuccess = createAction(
+    '[Policies] Load Policies Success',
+    props<{ response: { bucketId: string; policies: Policy[] } }>()
+);
+
+export const loadPoliciesFailure = createAction('[Policies] Load Policies 
Failure');
+
+export const loadPolicyTenants = createAction(
+    '[Policies] Load Policy Tenants',
+    props<{ request: { context: ErrorContextKey } }>()
+);
+
+export const loadPolicyTenantsSuccess = createAction(
+    '[Policies] Load Policy Tenants Success',
+    props<{ response: { users: PolicySubject[]; userGroups: PolicySubject[] } 
}>()
+);
+
+export const loadPolicyTenantsFailure = createAction('[Policies] Load Policy 
Tenants Failure');
+
+export const saveBucketPolicy = createAction(
+    '[Policies] Save Bucket Policy',
+    props<{
+        request: {
+            bucketId: string;
+            action: PolicySection;
+            policyId?: string;
+            users: PolicySubject[];
+            userGroups: PolicySubject[];
+            revision?: PolicyRevision;
+            isLastInBatch?: boolean;
+        };
+    }>()
+);
+
+export const saveBucketPolicyFailure = createAction('[Policies] Save Bucket 
Policy Failure');
+
+export const policyChangeSuccessToast = createAction(
+    '[Policies] Policy Change Success Toast',
+    props<{ message: string }>()
+);
+
+export const policiesNoOp = createAction('[Policies] No Op');
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.spec.ts
new file mode 100644
index 0000000000..82d656c62b
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.spec.ts
@@ -0,0 +1,276 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { Action } from '@ngrx/store';
+import { Observable, of, throwError } from 'rxjs';
+import { PoliciesEffects } from './policies.effects';
+import { BucketsService, Policy, PolicySubject } from 
'../../service/buckets.service';
+import { ErrorHelper } from '../../service/error-helper.service';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import * as PoliciesActions from './policies.actions';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ErrorContextKey } from '../error';
+
+const createPolicy = (overrides = {}): Policy => ({
+    identifier: 'policy-1',
+    action: 'read',
+    resource: '/buckets/bucket-1',
+    users: [],
+    userGroups: [],
+    revision: { version: 0 },
+    configurable: true,
+    ...overrides
+});
+
+const createPolicySubject = (overrides = {}): PolicySubject => ({
+    identifier: 'user-1',
+    identity: 'test-user',
+    type: 'user',
+    configurable: false,
+    ...overrides
+});
+
+describe('PoliciesEffects', () => {
+    let actions$: Observable<Action>;
+    let effects: PoliciesEffects;
+    let bucketsService: jest.Mocked<BucketsService>;
+    let errorHelper: jest.Mocked<ErrorHelper>;
+    let snackBar: jest.Mocked<MatSnackBar>;
+
+    beforeEach(() => {
+        const mockBucketsService = {
+            getPolicies: jest.fn(),
+            getBucketPolicyTenants: jest.fn(),
+            saveBucketPolicy: jest.fn()
+        };
+
+        const mockErrorHelper = {
+            getErrorString: jest.fn()
+        };
+
+        const mockSnackBar = {
+            open: jest.fn()
+        };
+
+        TestBed.configureTestingModule({
+            providers: [
+                PoliciesEffects,
+                provideMockActions(() => actions$),
+                { provide: BucketsService, useValue: mockBucketsService },
+                { provide: ErrorHelper, useValue: mockErrorHelper },
+                { provide: MatSnackBar, useValue: mockSnackBar }
+            ]
+        });
+
+        effects = TestBed.inject(PoliciesEffects);
+        bucketsService = TestBed.inject(BucketsService) as 
jest.Mocked<BucketsService>;
+        errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+        snackBar = TestBed.inject(MatSnackBar) as jest.Mocked<MatSnackBar>;
+    });
+
+    describe('loadPolicies$', () => {
+        it('should return loadPoliciesSuccess with policies on success', 
(done) => {
+            const policies = [createPolicy(), createPolicy({ identifier: 
'policy-2', action: 'write' })];
+            bucketsService.getPolicies.mockReturnValue(of(policies));
+
+            actions$ = of(
+                PoliciesActions.loadPolicies({
+                    request: { bucketId: 'bucket-1', context: 
ErrorContextKey.MANAGE_ACCESS }
+                })
+            );
+
+            effects.loadPolicies$.subscribe((action) => {
+                expect(action).toEqual(
+                    PoliciesActions.loadPoliciesSuccess({
+                        response: {
+                            bucketId: 'bucket-1',
+                            policies
+                        }
+                    })
+                );
+                done();
+            });
+        });
+
+        it('should return error actions on failure', (done) => {
+            const error = new HttpErrorResponse({ status: 500, statusText: 
'Server Error' });
+            bucketsService.getPolicies.mockReturnValue(throwError(() => 
error));
+            errorHelper.getErrorString.mockReturnValue('Error loading 
policies');
+
+            actions$ = of(
+                PoliciesActions.loadPolicies({
+                    request: { bucketId: 'bucket-1', context: 
ErrorContextKey.MANAGE_ACCESS }
+                })
+            );
+
+            effects.loadPolicies$.subscribe((action) => {
+                if (action.type === PoliciesActions.loadPoliciesFailure.type) {
+                    
expect(action).toEqual(PoliciesActions.loadPoliciesFailure());
+                    done();
+                }
+            });
+        });
+    });
+
+    describe('loadPolicyTenants$', () => {
+        it('should return loadPolicyTenantsSuccess with tenants on success', 
(done) => {
+            const users = [createPolicySubject()];
+            const userGroups = [createPolicySubject({ identifier: 'group-1', 
identity: 'test-group', type: 'group' })];
+            bucketsService.getBucketPolicyTenants.mockReturnValue(of({ users, 
userGroups }));
+
+            actions$ = of(PoliciesActions.loadPolicyTenants({ request: { 
context: ErrorContextKey.MANAGE_ACCESS } }));
+
+            effects.loadPolicyTenants$.subscribe((action) => {
+                expect(action).toEqual(
+                    PoliciesActions.loadPolicyTenantsSuccess({
+                        response: { users, userGroups }
+                    })
+                );
+                done();
+            });
+        });
+
+        it('should return error actions on failure', (done) => {
+            const error = new HttpErrorResponse({ status: 403, statusText: 
'Forbidden' });
+            
bucketsService.getBucketPolicyTenants.mockReturnValue(throwError(() => error));
+            errorHelper.getErrorString.mockReturnValue('Error loading 
tenants');
+
+            actions$ = of(PoliciesActions.loadPolicyTenants({ request: { 
context: ErrorContextKey.MANAGE_ACCESS } }));
+
+            effects.loadPolicyTenants$.subscribe((action) => {
+                if (action.type === 
PoliciesActions.loadPolicyTenantsFailure.type) {
+                    
expect(action).toEqual(PoliciesActions.loadPolicyTenantsFailure());
+                    done();
+                }
+            });
+        });
+    });
+
+    describe('saveBucketPolicy$', () => {
+        it('should save policy and reload when isLastInBatch is true', (done) 
=> {
+            const savedPolicy = createPolicy();
+            const allPolicies = [savedPolicy];
+            bucketsService.saveBucketPolicy.mockReturnValue(of(savedPolicy));
+            bucketsService.getPolicies.mockReturnValue(of(allPolicies));
+
+            actions$ = of(
+                PoliciesActions.saveBucketPolicy({
+                    request: {
+                        bucketId: 'bucket-1',
+                        action: 'read',
+                        users: [createPolicySubject()],
+                        userGroups: [],
+                        isLastInBatch: true
+                    }
+                })
+            );
+
+            let actionCount = 0;
+            effects.saveBucketPolicy$.subscribe((action) => {
+                actionCount++;
+                if (action.type === PoliciesActions.loadPoliciesSuccess.type) {
+                    expect(action).toEqual(
+                        PoliciesActions.loadPoliciesSuccess({
+                            response: {
+                                bucketId: 'bucket-1',
+                                policies: allPolicies
+                            }
+                        })
+                    );
+                }
+                if (action.type === 
PoliciesActions.policyChangeSuccessToast.type) {
+                    expect(action).toEqual(
+                        PoliciesActions.policyChangeSuccessToast({
+                            message: 'Bucket policies saved'
+                        })
+                    );
+                    expect(actionCount).toBe(2);
+                    done();
+                }
+            });
+        });
+
+        it('should save policy without reload when isLastInBatch is false', 
(done) => {
+            const savedPolicy = createPolicy();
+            bucketsService.saveBucketPolicy.mockReturnValue(of(savedPolicy));
+
+            actions$ = of(
+                PoliciesActions.saveBucketPolicy({
+                    request: {
+                        bucketId: 'bucket-1',
+                        action: 'read',
+                        users: [createPolicySubject()],
+                        userGroups: [],
+                        isLastInBatch: false
+                    }
+                })
+            );
+
+            effects.saveBucketPolicy$.subscribe((action) => {
+                expect(action).toEqual(PoliciesActions.policiesNoOp());
+                expect(bucketsService.getPolicies).not.toHaveBeenCalled();
+                done();
+            });
+        });
+
+        it('should return error actions on save failure', (done) => {
+            const error = new HttpErrorResponse({ status: 409, statusText: 
'Conflict' });
+            bucketsService.saveBucketPolicy.mockReturnValue(throwError(() => 
error));
+            errorHelper.getErrorString.mockReturnValue('Error saving policy');
+
+            actions$ = of(
+                PoliciesActions.saveBucketPolicy({
+                    request: {
+                        bucketId: 'bucket-1',
+                        action: 'read',
+                        users: [],
+                        userGroups: []
+                    }
+                })
+            );
+
+            effects.saveBucketPolicy$.subscribe((action) => {
+                if (action.type === 
PoliciesActions.saveBucketPolicyFailure.type) {
+                    
expect(action).toEqual(PoliciesActions.saveBucketPolicyFailure());
+                    done();
+                }
+            });
+        });
+    });
+
+    describe('policyChangeSuccessToast$', () => {
+        it('should open snackbar with message', (done) => {
+            actions$ = of(PoliciesActions.policyChangeSuccessToast({ message: 
'Success!' }));
+
+            effects.policyChangeSuccessToast$.subscribe(() => {
+                expect(snackBar.open).toHaveBeenCalledWith('Success!', 
'Dismiss', { duration: 3000 });
+                done();
+            });
+        });
+
+        it('should use default message if none provided', (done) => {
+            actions$ = of(PoliciesActions.policyChangeSuccessToast({ message: 
'' }));
+
+            effects.policyChangeSuccessToast$.subscribe(() => {
+                expect(snackBar.open).toHaveBeenCalledWith('Policy updated', 
'Dismiss', { duration: 3000 });
+                done();
+            });
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.ts
new file mode 100644
index 0000000000..f679928e94
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.ts
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { inject, Injectable } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { from, of } from 'rxjs';
+import { catchError, map, switchMap, tap, mergeMap } from 'rxjs/operators';
+import * as PoliciesActions from './policies.actions';
+import { BucketsService } from '../../service/buckets.service';
+import { ErrorHelper } from '../../service/error-helper.service';
+import * as ErrorActions from '../error/error.actions';
+
+@Injectable()
+export class PoliciesEffects {
+    private bucketsService = inject(BucketsService);
+    private errorHelper = inject(ErrorHelper);
+    private actions$ = inject(Actions);
+    private snackBar = inject(MatSnackBar);
+
+    loadPolicies$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(PoliciesActions.loadPolicies),
+            switchMap(({ request }) =>
+                from(this.bucketsService.getPolicies()).pipe(
+                    map((policies) =>
+                        PoliciesActions.loadPoliciesSuccess({
+                            response: {
+                                bucketId: request.bucketId,
+                                policies
+                            }
+                        })
+                    ),
+                    catchError((errorResponse: HttpErrorResponse) =>
+                        of(
+                            PoliciesActions.loadPoliciesFailure(),
+                            ErrorActions.addBannerError({
+                                errorContext: {
+                                    errors: 
[this.errorHelper.getErrorString(errorResponse)],
+                                    context: request.context
+                                }
+                            })
+                        )
+                    )
+                )
+            )
+        )
+    );
+
+    loadPolicyTenants$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(PoliciesActions.loadPolicyTenants),
+            switchMap(({ request }) =>
+                from(this.bucketsService.getBucketPolicyTenants()).pipe(
+                    map((response) => 
PoliciesActions.loadPolicyTenantsSuccess({ response })),
+                    catchError((errorResponse: HttpErrorResponse) =>
+                        of(
+                            PoliciesActions.loadPolicyTenantsFailure(),
+                            ErrorActions.addBannerError({
+                                errorContext: {
+                                    errors: 
[this.errorHelper.getErrorString(errorResponse)],
+                                    context: request.context
+                                }
+                            })
+                        )
+                    )
+                )
+            )
+        )
+    );
+
+    saveBucketPolicy$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(PoliciesActions.saveBucketPolicy),
+            mergeMap(({ request }) =>
+                from(
+                    this.bucketsService.saveBucketPolicy({
+                        bucketId: request.bucketId,
+                        action: request.action,
+                        policyId: request.policyId,
+                        users: request.users,
+                        userGroups: request.userGroups,
+                        revision: request.revision
+                    })
+                ).pipe(
+                    mergeMap(() => {
+                        // Only reload policies and show toast if this is the 
last in a batch
+                        if (request.isLastInBatch !== false) {
+                            return 
from(this.bucketsService.getPolicies()).pipe(
+                                mergeMap((policies) =>
+                                    of(
+                                        PoliciesActions.loadPoliciesSuccess({
+                                            response: {
+                                                bucketId: request.bucketId,
+                                                policies
+                                            }
+                                        }),
+                                        
PoliciesActions.policyChangeSuccessToast({
+                                            message: 'Bucket policies saved'
+                                        })
+                                    )
+                                )
+                            );
+                        }
+                        // For non-last saves, just complete without 
dispatching success
+                        return of(PoliciesActions.policiesNoOp());
+                    }),
+                    catchError((errorResponse: HttpErrorResponse) =>
+                        of(
+                            PoliciesActions.saveBucketPolicyFailure(),
+                            ErrorActions.snackBarError({ error: 
this.errorHelper.getErrorString(errorResponse) })
+                        )
+                    )
+                )
+            )
+        )
+    );
+
+    policyChangeSuccessToast$ = createEffect(
+        () =>
+            this.actions$.pipe(
+                ofType(PoliciesActions.policyChangeSuccessToast),
+                tap(({ message }) => {
+                    this.snackBar.open(message || 'Policy updated', 'Dismiss', 
{
+                        duration: 3000
+                    });
+                })
+            ),
+        { dispatch: false }
+    );
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.reducer.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.reducer.ts
new file mode 100644
index 0000000000..26fc451145
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.reducer.ts
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createReducer, on } from '@ngrx/store';
+import {
+    loadPolicies,
+    loadPoliciesFailure,
+    loadPoliciesSuccess,
+    loadPolicyTenants,
+    loadPolicyTenantsFailure,
+    loadPolicyTenantsSuccess,
+    saveBucketPolicy,
+    saveBucketPolicyFailure
+} from './policies.actions';
+import { PoliciesState } from '.';
+import { produce } from 'immer';
+
+export const initialState: PoliciesState = {
+    bucketId: null,
+    tenants: {
+        users: [],
+        userGroups: []
+    },
+    loadingPolicies: false,
+    loadingTenants: false,
+    saving: false,
+    error: null,
+    policySelection: {
+        read: undefined,
+        write: undefined,
+        delete: undefined
+    }
+};
+
+export const policiesReducer = createReducer(
+    initialState,
+    on(loadPolicies, (state) =>
+        produce(state, (draft) => {
+            draft.loadingPolicies = true;
+            draft.error = null;
+        })
+    ),
+    on(loadPoliciesSuccess, (state, { response }) =>
+        produce(state, (draft) => {
+            draft.bucketId = response.bucketId;
+            draft.loadingPolicies = false;
+            draft.saving = false; // Also clear saving state when policies 
reload
+
+            // Filter policies to only those matching this bucket
+            const bucketResource = `/buckets/${response.bucketId}`;
+            const bucketPolicies = response.policies.filter((policy) => 
policy.resource === bucketResource);
+
+            // Extract read, write, delete policies
+            const readPolicy = bucketPolicies.find((p) => p.action === 'read');
+            const writePolicy = bucketPolicies.find((p) => p.action === 
'write');
+            const deletePolicy = bucketPolicies.find((p) => p.action === 
'delete');
+
+            draft.policySelection = {
+                read: readPolicy
+                    ? {
+                          policyId: readPolicy.identifier,
+                          revision: readPolicy.revision,
+                          users: readPolicy.users,
+                          userGroups: readPolicy.userGroups
+                      }
+                    : undefined,
+                write: writePolicy
+                    ? {
+                          policyId: writePolicy.identifier,
+                          revision: writePolicy.revision,
+                          users: writePolicy.users,
+                          userGroups: writePolicy.userGroups
+                      }
+                    : undefined,
+                delete: deletePolicy
+                    ? {
+                          policyId: deletePolicy.identifier,
+                          revision: deletePolicy.revision,
+                          users: deletePolicy.users,
+                          userGroups: deletePolicy.userGroups
+                      }
+                    : undefined
+            };
+        })
+    ),
+    on(loadPoliciesFailure, (state) =>
+        produce(state, (draft) => {
+            draft.loadingPolicies = false;
+        })
+    ),
+    on(loadPolicyTenants, (state) =>
+        produce(state, (draft) => {
+            draft.loadingTenants = true;
+        })
+    ),
+    on(loadPolicyTenantsSuccess, (state, { response }) =>
+        produce(state, (draft) => {
+            draft.tenants = response;
+            draft.loadingTenants = false;
+        })
+    ),
+    on(loadPolicyTenantsFailure, (state) =>
+        produce(state, (draft) => {
+            draft.loadingTenants = false;
+        })
+    ),
+    on(saveBucketPolicy, (state) =>
+        produce(state, (draft) => {
+            draft.saving = true;
+        })
+    ),
+    on(saveBucketPolicyFailure, (state) =>
+        produce(state, (draft) => {
+            draft.saving = false;
+        })
+    )
+);
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.selectors.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.selectors.ts
new file mode 100644
index 0000000000..947dcbf9b5
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.selectors.ts
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import { policiesFeatureKey, BucketPolicyOptionsView, PoliciesState } from 
'./index';
+import { PolicySubject } from '../../service/buckets.service';
+import { resourcesFeatureKey, ResourcesState } from '..';
+
+export const selectResourcesState = 
createFeatureSelector<ResourcesState>(resourcesFeatureKey);
+
+export const selectPoliciesState = createSelector(selectResourcesState, 
(state) => state[policiesFeatureKey]);
+
+export const selectPolicyTenants = createSelector(selectPoliciesState, (state: 
PoliciesState) => state.tenants);
+
+export const selectPolicyOptions = createSelector(
+    selectPolicyTenants,
+    ({ users, userGroups }): BucketPolicyOptionsView => {
+        const sortedGroups = [...userGroups].sort((a, b) => 
a.identity.localeCompare(b.identity));
+        const sortedUsers = [...users].sort((a, b) => 
a.identity.localeCompare(b.identity));
+
+        const groups = sortedGroups.map((group) => ({
+            key: `group-${group.identifier}`,
+            label: group.identity,
+            type: 'group' as const,
+            subject: group
+        }));
+
+        const userOptions = sortedUsers.map((user) => ({
+            key: `user-${user.identifier}`,
+            label: user.identity,
+            type: 'user' as const,
+            subject: user
+        }));
+
+        const lookup: Record<string, PolicySubject> = {};
+        [...groups, ...userOptions].forEach((option) => {
+            lookup[option.subject.identifier] = option.subject;
+        });
+
+        return {
+            groups,
+            users: userOptions,
+            all: [...groups, ...userOptions],
+            lookup
+        };
+    }
+);
+
+export const selectPolicySelection = createSelector(
+    selectPoliciesState,
+    (state: PoliciesState) => state.policySelection
+);
+
+export const selectPoliciesLoading = createSelector(
+    selectPoliciesState,
+    (state: PoliciesState) => state.loadingPolicies || state.loadingTenants
+);
+
+export const selectPoliciesSaving = createSelector(selectPoliciesState, 
(state: PoliciesState) => state.saving);
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
index 3bc32df12d..3dac6537f2 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
@@ -22,7 +22,9 @@
         @if (flowRegistryClientDefinitionState.flowRegistryClientDefinition; 
as flowRegistryClientDefinition) {
             <div class="flex flex-col gap-y-4 p-4">
                 <configurable-extension-definition
-                    
[configurableExtensionDefinition]="flowRegistryClientDefinition"></configurable-extension-definition>
+                    [configurableExtensionDefinition]="
+                        flowRegistryClientDefinition
+                    "></configurable-extension-definition>
             </div>
         } @else if (flowRegistryClientDefinitionState.error) {
             <div class="p-4">
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
index 6d38b44c7d..f42b94314f 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
@@ -125,7 +125,7 @@ export class LocalChangesTable implements AfterViewInit {
     }
 
     canGoTo(item: LocalChange): boolean {
-        return (item.differenceType !== 'Component Removed') && 
(item.differenceType !== 'Component Bundle Changed');
+        return item.differenceType !== 'Component Removed' && 
item.differenceType !== 'Component Bundle Changed';
     }
 
     formatDifference(item: LocalChange): string {


Reply via email to