Permission discrepancy between Horde frontend and backend

We have identified a logic discrepancy between the Horde Dashboard (UI) and the Horde Backend (Horde.Server) regarding how JobAclAction.CreateJob is authorized. Our intention was to grant users view only permissions (ViewStream] and [ViewTemplate) in global config then selectively grant some users [CreateJob] permission for specific templates.

While the UI correctly evaluates effective permissions at the Template level - allowing a user to see and click the “Create Job” button if they have a Template-level override - the Backend API rejects the request with “User does not have permission for stream” error unless the user also has the permission at the Stream level. After investigation we found that root cause is this:

In JobsController.cs, the CreateJobAsync method performs a sequential check that requires a Stream-level grant before it ever evaluates the Template-level ACL. This early exit prevents the intended use case of granting a user the ability to run only specific templates while restricting them from the rest of the stream (see the code snippet below).

public async Task<ActionResult<CreateJobResponse>> CreateJobAsync([FromBody] CreateJobRequest create, CancellationToken cancellationToken = default)
		{
			StreamConfig? streamConfig;
			if (!_buildConfig.Value.TryGetStream(create.StreamId, out streamConfig))
			{
				return NotFound(create.StreamId);
			}
			if (!streamConfig.Authorize(JobAclAction.CreateJob, User)) // is this necessary?
			{
				return Forbid(JobAclAction.CreateJob, streamConfig.Id);
			}
 
			if (create.RunAsScheduler == true && !_buildConfig.Value.Authorize(ServerAclAction.Debug, User))
			{
				return Forbid(ServerAclAction.Debug);
			}
 
			// Get the name of the template ref
			TemplateId templateRefId = create.TemplateId;
 
			// Augment the request with template properties
			TemplateRefConfig? templateRefConfig;
			if (!streamConfig.TryGetTemplate(templateRefId, out templateRefConfig))
			{
				return BadRequest($"Template {create.TemplateId} is not available for stream {streamConfig.Id}");
			}
			if (!templateRefConfig.Authorize(JobAclAction.CreateJob, User))
			{
				return Forbid(JobAclAction.CreateJob, streamConfig.Id);
			}

We are therefore forced to grant [CreateJob] at the Stream level and then manually break inheritance on every other template to eliminate unauthorized access, which is difficult to maintain at scale.

To work around this for now we found that if we grant users CreateJob permission at the stream level then create a base template in which we rekove that permission and then extend this base for all templates and only grant the permission for the intended templates.

To summarize, intuitively you’d want:

  • Stream: no [CreateJob]
  • Template: grant [CreateJob] for specific jobs

But instead you have to:

  • Stream: grant [CreateJob]
  • Base template: revoke [CreateJob] (ACL override, no inheritance)
  • Specific templates: re-grant [CreateJob]

It looks like removing the “if (!streamConfig.Authorize(JobAclAction.CreateJob, User))” block could achieve what we need but unsure if that condition is necessary for other purposes.

Can you get back to me with your thoughts on this? Is this intentional behaviour and we should use workarounds or is this an unintentional behaviour and we should attempt a fix in the Horde code?

Cheers, Daniel

[Attachment Removed]

Steps to Reproduce:

  1. Configure a Stream where a user has [ViewStream] and [ViewTemplate] permissions, but not [CreateJob] at the Stream level.
  2. Configure a specific Template within that Stream with an ACL override that grants the user [CreateJob].
  3. In the Horde UI, observe that the user can see the “Create Job” button for that specific template.
  4. Click the button to start the job.
  5. Notice that the UI request to POST /api/v1/jobs fails with a 403 Forbidden error: “User does not have permission for stream <streamId>”.

[Attachment Removed]

Thank you for diving into the internals and for the detailed write-up of your investigation into this.

We don’t seem to have many examples of template-based ACLs internally, so this may be something that has gone unnoticed rather than intentional behavior.

Our permissions are generally pretty loosely grained around project-run and project-view, where not having access to a stream would preclude running anything on it.

Could you share some high-level insight into the workflows that this template-level ACL would allow you to set up?

[Attachment Removed]

Thank you for writing up your use case; it was very helpful when discussing this with the team! I’ve filed a ticket to fix the discrepancy between the Jobs API and the Streams API: UE-380887.

Unfortunately, for now, you will need to continue using the base template approach for template-level overrides, as we’re not able to commit to implementing Template ACL overrides at this time.

[Attachment Removed]

Yes, sure. We have 3 profiles set up: Viewer, Developer and Maintainer.

"profiles" : [
    {
        "id": "Viewer",
        "actions" : [
            "ViewAccount",
            "ListAgents",
            "ViewAgent",
            "ViewLeases",
            "ViewLeaseTasks",
            "ViewLog",
            "ViewEvent",
            "ViewPool",
            "ListPools",
            "ReadArtifact",
            "DownloadArtifact",
            "ViewJob",
            "CreateSubscription",
            "ViewProject",
            "ViewStream",
            "ViewTemplate",
            "DownloadTool"
        ]
    },
    {
        "id" : "Developer",
        "extends" : [ "Viewer" ],
        "actions": [
            "CreateJob",
            "RetryJobStep",
            "ReadDevice"
        ]
    },
    {
        "id" : "Maintainer",
        "extends" : [ "Developer" ],
        "actions": [
            "ViewSession",
            "DeleteArtifact",
            "UpdateJob",
            "DeleteJob"
        ]
    }
]

We intended to have assign the team Viewer profile permissions by default in the global config.

"acl": {
    "entries": [
        { // GROUP: Everyone in the studio
            "claim": {
                "type": "groups",
                "value": "..." // studio group id
            },
            "profiles" : [ "Viewer" ]
        },
        { // GROUP: Maintainers
            "claim": {
                "type": "groups",
                "value": "..." // Horde maintainers group id
            },
            "profiles" : [ "Maintainer" ]
        }
    ]
}

Then for specific jobs have a group be given Developer profile permissions by overriding the acl in the job template.

{
    "id": "build-game-job-template-id",
    "acl": {
        "entries": [
            { // GROUP: Project team
                "claim": {
                    "type": "groups",
                    "value": "..." // team group id
                },
                "profiles" : [ "Developer" ]
            }
        ]
    }
}

The main goal is to only allow certain jobs to be kicked off while all users would be able to see all jobs. This is so that specialized jobs running on a schedule cannot be started manually by anyone outside a group of people.

In addition we also planned to have Maintainer profile permissions be assigned to a few people in the default config. This is separate to Horde administrators which don’t have any restriction. So, you see, we didn’t have a plan to set the permissions at the stream level but rather at template level to override the default permissions.

The early check of stream level CreateJob permission seems to be against the inheritance and extension feature at the template level as you can see. We are forced to assign CreateJob permissions at the stream level and revoke these in the templates.

Further more, as far as I can tell from the code, the rest of the permissions cannot even be specified at the template level the same as for CreateJob. All the other actions seem to not check the template for these rules. But we are still interested in configuring CreateJob permissions as this is the most important one for our goals.

I hope this information is helpful but let me know if anything is still unclear.

Cheers, Daniel

[Attachment Removed]

Hi, just following up on this. We ended up modifying the engine code because using a base template to revoke permissions granted at stream level was forcing us to disable inheritance and we needed that for other reasons. Below are our fixes for both CreateJob and RetryJobSteps. Changes are wrapped in #region HordeFixCreateJobAcl.

[HttpPost]
[Route("/api/v1/jobs")]
public async Task<ActionResult<CreateJobResponse>> CreateJobAsync([FromBody] CreateJobRequest create, CancellationToken cancellationToken = default)
{
    StreamConfig? streamConfig;
    if (!_buildConfig.Value.TryGetStream(create.StreamId, out streamConfig))
    {
        return NotFound(create.StreamId);
    }
    #region HordeFixTemplateACLCheck
    // Removed: stream-level CreateJob check prevented template-level grants from taking effect.
    // Permission is now evaluated at the template level below, with the stream-level check retained only as a fallback for accurate error reporting.
    // if (!streamConfig.Authorize(JobAclAction.CreateJob, User))
    // {
    // 	return Forbid(JobAclAction.CreateJob, streamConfig.Id);
    // }
    #endregion
 
    if (create.RunAsScheduler == true && !_buildConfig.Value.Authorize(ServerAclAction.Debug, User))
    {
        return Forbid(ServerAclAction.Debug);
    }
 
    // Get the name of the template ref
    TemplateId templateRefId = create.TemplateId;
 
    // Augment the request with template properties
    TemplateRefConfig? templateRefConfig;
    if (!streamConfig.TryGetTemplate(templateRefId, out templateRefConfig))
    {
        return BadRequest($"Template {create.TemplateId} is not available for stream {streamConfig.Id}");
    }
 
    #region HordeFixTemplateACLCheck
    if (!templateRefConfig.Authorize(JobAclAction.CreateJob, User))
    {
        // Distinguish between stream-level and template-level denial
        // to provide an accurate error message.
        if (!streamConfig.Authorize(JobAclAction.CreateJob, User))
        {
            return Forbid(JobAclAction.CreateJob, streamConfig.Id);
        }
        return Forbid(JobAclAction.CreateJob, templateRefConfig.Id);
    }
    #endregion
 
    ITemplate? template = await _templateCollection.GetOrAddAsync(templateRefConfig);
    if (template == null)
    {
        return BadRequest("Missing template referenced by {TemplateId}", create.TemplateId);
    }
    if (!template.AllowPreflights && create.PreflightCommitId != null)
    {
        return BadRequest("Template {TemplateId} does not allow preflights", create.TemplateId);
    }
[HttpPut]
[Route("/api/v1/jobs/{jobId}/batches/{batchId}/steps/{stepId}")]
public async Task<ActionResult<UpdateStepResponse>> UpdateStepAsync(JobId jobId, JobStepBatchId batchId, JobStepId stepId, [FromBody] UpdateStepRequest request)
{
    IJob? job = await _jobService.GetJobAsync(jobId);
    if (job == null)
    {
        return NotFound(jobId);
    }
 
    StreamConfig? streamConfig;
    if (!_buildConfig.Value.TryGetStream(job.StreamId, out streamConfig))
    {
        return NotFound(job.StreamId);
    }
 
    // Check permissions for updating this step. Only the agent executing the step can modify the state of it.
    if (request.State != JobStepState.Unspecified || request.Outcome != JobStepOutcome.Unspecified)
    {
        IJobStepBatch? batch = job.Batches.FirstOrDefault(x => x.Id == batchId);
        if (batch == null)
        {
            return NotFound(jobId, batchId);
        }
        if (!batch.SessionId.HasValue || !User.HasSessionClaim(batch.SessionId.Value))
        {
            return Forbid();
        }
    }
 
    #region HordeFixTemplateACLCheck
    // Stream-only check replaced with template-first check for per-template ACL support.
    if (request.Retry != null || request.Priority != null)
    {
        if (streamConfig.TryGetTemplate(job.TemplateId, out TemplateRefConfig? templateRefConfig))
        {
            if (!templateRefConfig.Authorize(JobAclAction.RetryJobStep, User))
            {
                // Distinguish between stream-level and template-level denial
                // to provide an accurate error message.
                if (!streamConfig.Authorize(JobAclAction.RetryJobStep, User))
                {
                    return Forbid(JobAclAction.RetryJobStep, streamConfig.Id);
                }
                return Forbid(JobAclAction.RetryJobStep, templateRefConfig.Id);
            }
        }
        else if (!streamConfig.Authorize(JobAclAction.RetryJobStep, User))
        {
            return Forbid(JobAclAction.RetryJobStep, streamConfig.Id);
        }
    }
    #endregion
    if (request.Properties != null)
    {
        if (!streamConfig.Authorize(JobAclAction.UpdateJob, User))
        {
            return Forbid(JobAclAction.UpdateJob, jobId);
        }
    }

[Attachment Removed]