fullstackhero/dotnet-starter-kit · error · ForbiddenException

Only the root tenant can set the default theme

Error message

Only the root tenant can set the default theme

What it means

TenantThemeService.SetAsDefaultThemeAsync requires the caller's current tenant (from the Finbuckle multitenant context) to be the root tenant; otherwise it throws ForbiddenException. Only the root/host tenant may designate a theme as the tenant-wide default.

Solutions

  1. Call the endpoint as the root tenant (correct host/tenant header so Finbuckle resolves Root.Id).
  2. Use the admin app / root-tenant credentials for default-theme management.
  3. Ensure the request carries tenant resolution info (identifier header/host) so MultiTenantContext.TenantInfo is populated.
  4. If a tenant wants custom branding, use per-tenant theme APIs instead of SetAsDefaultThemeAsync.

Example fix

// before
// called from a normal tenant's session
await themeClient.SetAsDefaultThemeAsync(themeId);

// after
var currentTenant = httpContext.GetMultiTenantContext<TenantInfo>()?.TenantInfo?.Id;
if (currentTenant != MultitenancyConstants.Root.Id)
{
    throw new ForbiddenException("Switch to the root tenant (admin app) to set the default theme.");
}
await themeClient.SetAsDefaultThemeAsync(themeId);
Defensive patterns

Strategy: validation

Validate before calling

var currentTenantId = httpContext.GetMultiTenantContext<TenantInfo>()?.TenantInfo?.Id;
if (currentTenantId != MultitenancyConstants.Root.Id)
    throw new ForbiddenException("Default theme can only be set by the root tenant.");

Type guard

bool IsRootContext(TenantInfo? ti) => ti?.Id == MultitenancyConstants.Root.Id;

Try / catch

try
{
    await themeService.SetAsDefaultThemeAsync(tenantId, themeId, ct);
}
catch (ForbiddenException)
{
    logger.LogWarning("Non-root tenant {TenantId} attempted to set default theme", currentTenantId);
}

Prevention

When it happens

Trigger: Calling SetAsDefaultThemeAsync while authenticated under a non-root tenant context (MultiTenantContext.TenantInfo.Id != Root.Id); calling from the dashboard app of a normal tenant; calling without tenant resolution at all so TenantInfo (or its Id) is null and the != comparison fails.

Common situations: Tenant admin attempting to force their branding on all tenants; integration scripts not resolving into a tenant context (no tenant header/host mapping, so currentTenantId is null); using the wrong API client (dashboard instead of admin/root).

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/dcbb6985c15f33e7. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs:223

        }

        await InvalidateCacheAsync(tenantId, ct).ConfigureAwait(false);

        if (_logger.IsEnabled(LogLevel.Information))
        {
            _logger.LogInformation("Reset theme to defaults for tenant {TenantId}", tenantId);
        }
    }

    public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);

        // Ensure only root tenant can set default theme
        var currentTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id;
        if (currentTenantId != MultitenancyConstants.Root.Id)
        {
            throw new ForbiddenException("Only the root tenant can set the default theme");
        }

        // Clear existing default
        var existingDefault = await _dbContext.TenantThemes
            .FirstOrDefaultAsync(t => t.IsDefault, ct)
            .ConfigureAwait(false);

        if (existingDefault is not null)
        {
            existingDefault.IsDefault = false;
        }

        // Set new default
        var entity = await _dbContext.TenantThemes
            .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct)
            .ConfigureAwait(false);

        if (entity is null)

View on GitHub (pinned to 3f2959e683)