Skip to content
38 changes: 23 additions & 15 deletions src/app/agents/new-agent/new-agent.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@
<app-page-title title="New Agent" [subbutton]="false"></app-page-title>
<mat-stepper #stepper>
<mat-step label="Download Agent Binary" state="download">
<app-agent-binaries-table

[isSelectable]="false"
></app-agent-binaries-table>
@if (canReadAgentBinaries) {
<app-agent-binaries-table
[isSelectable]="false"
></app-agent-binaries-table>
}
<div class="highlight-box">
<p>
<strong>Download the agent binary</strong> and execute it on the
client server. You can download an agent binary by clicking on the
action menu (...) in the agent binaries table above.
</p>
@if (canReadAgentBinaries) {
<p>
<strong>Download the agent binary</strong> and execute it on the
client server. You can download an agent binary by clicking on the
action menu (...) in the agent binaries table above.
</p>
} @else {
<p>
You do not have permission to view agent binaries. You can skip this step and proceed to register the agent.
</p>
}
</div>
<div>
<button-submit mat-button (click)="stepper.next()" name="Next"></button-submit>
Expand Down Expand Up @@ -53,16 +60,17 @@
</form>
<app-vouchers-table #table></app-vouchers-table>
<div class="highlight-box">
<p *ngIf="!allowMultiVoucher; else multipleVouchersMessage">
<strong>Generate a voucher</strong> to register the agent. Note that once the voucher
is used it will be automatically deleted.
</p>
<ng-template #multipleVouchersMessage>
@if (!allowMultiVoucher) {
<p>
<strong>Generate a voucher</strong> to register the agent. Note that once the voucher
is used it will be automatically deleted.
</p>
} @else {
<p>
<strong>Generate vouchers</strong> to register agents. Vouchers remain available when the
"Register Multiple Agents Using Voucher(s)" option is enabled.
</p>
</ng-template>
}
</div>
<div>
<button-submit mat-button (click)="stepper.previous()" name="Back" type="delete"></button-submit>
Expand Down
47 changes: 46 additions & 1 deletion src/app/agents/new-agent/new-agent.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Perm } from '@constants/userpermissions.config';
import { of } from 'rxjs';

import { Clipboard } from '@angular/cdk/clipboard';
Expand All @@ -13,6 +14,7 @@ import { Router } from '@angular/router';

import { SERV } from '@services/main.config';
import { GlobalService } from '@services/main.service';
import { PermissionService } from '@services/permission/permission.service';
import { AlertService } from '@services/shared/alert.service';
import { ConfigService } from '@services/shared/config.service';

Expand All @@ -29,7 +31,7 @@ import { mockResponse } from '@src/app/testing/mock-response';
standalone: false
})
export class MockAgentBinariesTableComponent {
@Input() isSelectable: boolean;
@Input() isSelectable: boolean = false;
}

// Voucher table mock
Expand All @@ -49,13 +51,16 @@ describe('NewAgentComponent', () => {
let alertServiceSpy: jasmine.SpyObj<AlertService>;
let configServiceSpy: jasmine.SpyObj<ConfigService>;
let globalServiceSpy: jasmine.SpyObj<GlobalService>;
let permissionServiceSpy: jasmine.SpyObj<PermissionService>;
let routerSpy: jasmine.SpyObj<Router>;

beforeEach(async () => {
clipboardSpy = jasmine.createSpyObj('Clipboard', ['copy']);
alertServiceSpy = jasmine.createSpyObj('AlertService', ['showSuccessMessage']);
configServiceSpy = jasmine.createSpyObj('ConfigService', ['getEndpoint']);
globalServiceSpy = jasmine.createSpyObj('GlobalService', ['create', 'getAll']);
permissionServiceSpy = jasmine.createSpyObj('PermissionService', ['hasPermissionSync']);
permissionServiceSpy.hasPermissionSync.and.returnValue(true);
routerSpy = jasmine.createSpyObj('Router', ['navigate']);

// Provide default stub for configService.getEndpoint()
Expand All @@ -81,6 +86,7 @@ describe('NewAgentComponent', () => {
{ provide: AlertService, useValue: alertServiceSpy },
{ provide: ConfigService, useValue: configServiceSpy },
{ provide: GlobalService, useValue: globalServiceSpy },
{ provide: PermissionService, useValue: permissionServiceSpy },
{ provide: Router, useValue: routerSpy }
]
}).compileComponents();
Expand Down Expand Up @@ -176,4 +182,43 @@ describe('NewAgentComponent', () => {
component.ngOnDestroy();
expect(component.newVoucherSubscription.unsubscribe).toHaveBeenCalled();
});

it('should allow agent creation for a user without AccessGroup.READ permission', fakeAsync(() => {
// Simulate a user who lacks AccessGroup.READ but retains Agent.CREATE, Agent.READ and Voucher.READ
// This is the exact scenario described in issue #1955
permissionServiceSpy.hasPermissionSync.and.callFake((perm: string) => perm !== Perm.GroupAccess.READ);

fixture = TestBed.createComponent(NewAgentComponent);
component = fixture.componentInstance;
fixture.detectChanges();

// Component initializes without errors
expect(component).toBeTruthy();

// User can still create a voucher — the actual agent registration action
const voucher = 'fy7vjq56';
component.form.controls['voucher'].setValue(voucher);
component.table = jasmine.createSpyObj('VouchersTableComponent', ['reload']);
globalServiceSpy.create.and.returnValue(of({} as any));

component.onSubmit();
tick();

expect(globalServiceSpy.create).toHaveBeenCalledWith(SERV.VOUCHER, { voucher: voucher });
expect(alertServiceSpy.showSuccessMessage).toHaveBeenCalledWith('New voucher successfully created!');
}));

it('should not make any access group API call during agent creation', () => {
// Agent creation does not require or fetch access groups — verifies no extraneous dependency
const calledEndpoints = globalServiceSpy.getAll.calls.allArgs().map((args: unknown[]) => args[0]);
expect(calledEndpoints).not.toContain(SERV.ACCESS_GROUPS);
});

it('should hide agent binaries table and avoid 403 when user lacks AgentBinary.READ permission', () => {
// Secondary fix: users without AgentBinary.READ won't trigger a 403 on the binaries endpoint
component.canReadAgentBinaries = false;
fixture.detectChanges();
const table = fixture.nativeElement.querySelector('app-agent-binaries-table');
expect(table).toBeNull();
});
});
4 changes: 4 additions & 0 deletions src/app/agents/new-agent/new-agent.component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Perm } from '@constants/userpermissions.config';
import { zConfigListResponse } from '@generated/api/zod';
import { Subscription } from 'rxjs';
import { VouchersTableComponent } from 'src/app/core/_components/tables/vouchers-table/vouchers-table.component';
Expand All @@ -13,6 +14,7 @@ import { Router } from '@angular/router';
import { JConfig } from '@models/configs.model';

import { SERV } from '@services/main.config';
import { PermissionService } from '@services/permission/permission.service';
import { AlertService } from '@services/shared/alert.service';

import { VoucherForm } from '@src/app/agents/new-agent/new-agent.form';
Expand All @@ -32,6 +34,7 @@ export class NewAgentComponent implements OnInit, OnDestroy {
private alertService = inject(AlertService);
private cs = inject(ConfigService);
private gs = inject(GlobalService);
private permissionService = inject(PermissionService);
private router = inject(Router);

form: FormGroup<VoucherForm> = new FormGroup<VoucherForm>({
Expand All @@ -40,6 +43,7 @@ export class NewAgentComponent implements OnInit, OnDestroy {
agentURL: string;
newVoucherSubscription: Subscription;
allowMultiVoucher = false;
canReadAgentBinaries = this.permissionService.hasPermissionSync(Perm.AgentBinary.READ);

@ViewChild('table') table: VouchersTableComponent;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<ht-table #table name="accessPermissionGroupsUserTable" dataType="access-permission-groups-user"
[columnLabels]="columnLabels" [dataSource]="dataSource" [tableColumns]="tableColumns" [isSelectable]="true"
[isFilterable]="isFilterable" filterMode="client" [isPageable]="true" [isDetailPage]="true"
(editableCheckbox)="onCheckboxChange($event)" (exportActionClicked)="exportActionClicked($event)" />
(editableCheckbox)="onCheckboxChange($event)" (exportActionClicked)="exportActionClicked($event)"
(allToggled)="onAllPermissionsToggled()" (rowToggled)="onRowToggled($event)" />
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { catchError } from 'rxjs';

import { AfterViewInit, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, Component, Input, OnDestroy, OnInit, inject } from '@angular/core';

import { DynamicModel } from '@models/base.model';
import { UserPermissions } from '@models/global-permission-group.model';
Expand Down Expand Up @@ -30,6 +31,8 @@ export class AccessPermissionGroupsUserTableComponent
{
@Input() accesspermgroupId = 0;

private http = inject(HttpClient);

tableColumns: HTTableColumn[] = [];
dataSource: AccessPermissionGroupsExpandDataSource;
expand = 'userMembers';
Expand Down Expand Up @@ -148,27 +151,112 @@ export class AccessPermissionGroupsUserTableComponent
const capitalizedPerm = (editable['action'].match(/-(.*?)-/)?.[1] || '')
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase());
const keyPerm = String((editable.data as unknown as DynamicModel)['originalName']) + capitalizedPerm;
const keyPerm = 'perm' + String((editable.data as unknown as DynamicModel)['key']) + capitalizedPerm;
const boolValue = value === 'true' ? true : value === 'false' ? false : Boolean(value);
// Payload
const payload = {
permissions: { [keyPerm]: boolValue }
};

const request$ = this.gs.update(SERV.ACCESS_PERMISSIONS_GROUPS, this.accesspermgroupId, payload);
// Build the full permissions object from current datasource state, then apply the change
const allPermissions: Record<string, boolean> = {};
(this.dataSource.currentData as UserPermissions[]).forEach((perm) => {
allPermissions[`perm${perm.key}Create`] = perm.create;
allPermissions[`perm${perm.key}Read`] = perm.read;
allPermissions[`perm${perm.key}Update`] = perm.update;
allPermissions[`perm${perm.key}Delete`] = perm.delete;
});
allPermissions[keyPerm] = boolValue;

this.patchPermissions(
allPermissions,
`Changed permission in ${capitalizedPerm} on Permission Group #${this.accesspermgroupId}!`
);
}

/**
* Grants or revokes all 4 permissions (create/read/update/delete) for a single resource row.
* Direction is derived from the row's current data: if any permission is already true,
* revoke all; otherwise grant all. This avoids relying on the selection checkbox state
* which resets to unchecked after every reload.
*/
onRowToggled(event: { row: JUser | UserPermissions; checked: boolean }): void {
const perm = event.row as UserPermissions;
if (!('key' in perm) || !perm.key) return;

const allGranted = perm.create && perm.read && perm.update && perm.delete;
const newValue = !allGranted;

const allPermissions: Record<string, boolean> = {};
(this.dataSource.currentData as UserPermissions[]).forEach((p) => {
allPermissions[`perm${p.key}Create`] = p.create;
allPermissions[`perm${p.key}Read`] = p.read;
allPermissions[`perm${p.key}Update`] = p.update;
allPermissions[`perm${p.key}Delete`] = p.delete;
});
allPermissions[`perm${perm.key}Create`] = newValue;
allPermissions[`perm${perm.key}Read`] = newValue;
allPermissions[`perm${perm.key}Update`] = newValue;
allPermissions[`perm${perm.key}Delete`] = newValue;

const label = newValue
? `Granted all permissions for ${perm.name} on Permission Group #${this.accesspermgroupId}`
: `Revoked all permissions for ${perm.name} on Permission Group #${this.accesspermgroupId}`;
this.patchPermissions(allPermissions, label);
}

/**
* Grants or revokes all permissions for all resources at once.
* Direction is determined by the current data: if more than half the individual
* permission booleans are already true, the next click revokes; otherwise it grants.
* This correctly handles server-enforced always-false permissions (e.g. AgentError Create/Update)
* that prevent a strict "all === true" check from ever being satisfied.
*/
onAllPermissionsToggled(): void {
const currentPerms = this.dataSource.currentData as UserPermissions[];
if (currentPerms.length === 0) return;

const totalPerms = currentPerms.length * 4;
const grantedCount = currentPerms.reduce(
(count, p) => count + (p.create ? 1 : 0) + (p.read ? 1 : 0) + (p.update ? 1 : 0) + (p.delete ? 1 : 0),
0
);
const newValue = grantedCount / totalPerms <= 0.5; // grant when ≤50% granted, revoke when >50%

const allPermissions: Record<string, boolean> = {};
currentPerms.forEach((perm) => {
allPermissions[`perm${perm.key}Create`] = newValue;
allPermissions[`perm${perm.key}Read`] = newValue;
allPermissions[`perm${perm.key}Update`] = newValue;
allPermissions[`perm${perm.key}Delete`] = newValue;
});
const label = newValue
? `Granted all permissions on Permission Group #${this.accesspermgroupId}`
: `Revoked all permissions on Permission Group #${this.accesspermgroupId}`;
this.patchPermissions(allPermissions, label);
}

/**
* Sends a PATCH to update the global permission group's permissions object.
* Uses a direct HTTP call with the exact JSON:API type the server expects.
*/
private patchPermissions(permissions: Record<string, boolean>, successMessage: string): void {
const url = `${this.cs.getEndpoint()}${SERV.ACCESS_PERMISSIONS_GROUPS.URL}/${this.accesspermgroupId}`;
const body = {
data: {
type: 'globalPermissionGroup',
id: this.accesspermgroupId,
attributes: { permissions }
}
};
this.subscriptions.push(
request$
this.http
.patch(url, body)
.pipe(
catchError((error) => {
this.alertService.showErrorMessage(`Failed to update permission!`);
console.error('Failed to update permission:', error);
this.alertService.showErrorMessage(`Failed to update permissions!`);
console.error('Failed to update permissions:', error);
return [];
})
)
.subscribe(() => {
this.alertService.showSuccessMessage(
`Changed permission in ${capitalizedPerm} on Permission Group #${this.accesspermgroupId}!`
);
this.alertService.showSuccessMessage(successMessage);
this.reload();
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@
}"
>
@if (!isCmdTask) {
<mat-checkbox (change)="toggleSelect(row)" (click)="$event.stopPropagation()" [checked]="isSelected(row)" />
<mat-checkbox (change)="toggleSelect(row, $event.checked)" (click)="$event.stopPropagation()" [checked]="isSelected(row)" />
} @else {
<!-- CMD Task -->
<ng-container>
Expand Down
14 changes: 13 additions & 1 deletion src/app/core/_components/tables/ht-table/ht-table.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ export class HTTableComponent<T extends BaseModel> implements OnInit, AfterViewI
/** Fetches user customizations */
@Output() backendSqlFilter: EventEmitter<string> = new EventEmitter();

/** Emits true when all rows become selected, false when all become deselected via the header checkbox */
@Output() allToggled = new EventEmitter<boolean>();

/** Emits when a single row checkbox is toggled, with the row data and new checked state */
@Output() rowToggled = new EventEmitter<{ row: T; checked: boolean }>();

private uiSettings: UISettingsUtilityClass;
private subscriptions: Subscription = new Subscription();

Expand Down Expand Up @@ -511,6 +517,8 @@ export class HTTableComponent<T extends BaseModel> implements OnInit, AfterViewI
*/
toggleAll(): void {
this.dataSource.toggleAll();
this.cd.markForCheck();
this.allToggled.emit(this.dataSource.isAllSelected());
}

/**
Expand All @@ -532,9 +540,13 @@ export class HTTableComponent<T extends BaseModel> implements OnInit, AfterViewI
*
* @param row - The row to toggle.
*/
toggleSelect(row: T): void {
toggleSelect(row: T, checked?: boolean): void {
if (this.isSelectable) {
this.dataSource.toggleRow(row);
this.cd.markForCheck();
if (checked !== undefined) {
this.rowToggled.emit({ row, checked });
}
}
}

Expand Down
Loading
Loading