Skip to content

Commit 3e940de

Browse files
committed
user seed
1 parent 5ab21c5 commit 3e940de

3 files changed

Lines changed: 214 additions & 12 deletions

File tree

Services/Program.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -930,9 +930,9 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
930930
return Results.Content(SetupPages.IndexHtml, "text/html");
931931
});
932932

933-
app.MapGet("/api/setup/status", (HttpContext context) =>
933+
app.MapGet("/api/setup/status", async (HttpContext context) =>
934934
{
935-
var status = setupService.GetStatus();
935+
var status = await setupService.GetStatus(context.RequestAborted);
936936
return Results.Json(status);
937937
});
938938

@@ -1005,6 +1005,25 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
10051005
await setupService.ConfigureManual(appId, clientSecret, tenantId, multiTenant);
10061006
return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." });
10071007
});
1008+
1009+
app.MapPost("/api/setup/seed-user", async (HttpContext context) =>
1010+
{
1011+
try
1012+
{
1013+
using var reader = new StreamReader(context.Request.Body);
1014+
var body = await reader.ReadToEndAsync();
1015+
using var doc = System.Text.Json.JsonDocument.Parse(body);
1016+
var root = doc.RootElement;
1017+
1018+
var upn = root.GetProperty("upn").GetString()!;
1019+
await setupService.SeedFirstUser(upn, context.RequestAborted);
1020+
return Results.Json(new { success = true, message = $"Superadmin user {upn} added successfully." });
1021+
}
1022+
catch (Exception ex)
1023+
{
1024+
return Results.Json(new { success = false, message = ex.Message }, statusCode: 400);
1025+
}
1026+
});
10081027
} // end Setup.Enabled
10091028

10101029
// --- Job Status API (C# direct — no PS overhead) ---

Services/SetupPages.cs

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ public static class SetupPages
9191
font-size: 0.85rem; color: #94a3b8; margin-top: 0.5rem;
9292
}
9393
.device-code-box a { color: #60a5fa; text-decoration: underline; }
94+
.disabled-section { opacity: 0.4; pointer-events: none; }
9495
</style>
9596
</head>
9697
<body>
@@ -100,9 +101,21 @@ public static class SetupPages
100101
101102
<div id="status-banner" class="info-banner hidden"></div>
102103
103-
<!-- Option 1: Automated Setup (Device Code Flow) -->
104-
<div class="card" id="auto-section">
105-
<h2>Automated Setup</h2>
104+
<!-- Step 1: First User -->
105+
<div class="card" id="user-section">
106+
<h2>Step 1: First User</h2>
107+
<p>Add the first superadmin user before configuring authentication. This user will have full access to the application.</p>
108+
<label for="seed-upn">User Principal Name (email)</label>
109+
<input type="text" id="seed-upn" placeholder="admin@contoso.com">
110+
<button class="btn btn-primary" id="btn-seed" onclick="seedFirstUser()" disabled>Add Superadmin</button>
111+
<div id="seed-status" class="status"></div>
112+
</div>
113+
114+
<div class="divider" id="divider-user"></div>
115+
116+
<!-- Step 2: Automated Setup (Device Code Flow) -->
117+
<div class="card disabled-section" id="auto-section">
118+
<h2>Step 2a: Automated Setup</h2>
106119
<p>Sign in with a Global Administrator account to automatically create the EasyAuth app registration and configure this App Service.</p>
107120
108121
<label style="margin-bottom: 0.25rem;">Tenant Access</label>
@@ -131,11 +144,11 @@ public static class SetupPages
131144
<div id="auto-status" class="status"></div>
132145
</div>
133146
134-
<div class="divider"></div>
147+
<div class="divider" id="divider-auth"></div>
135148
136-
<!-- Option 2: Manual Setup -->
137-
<div class="card" id="manual-section">
138-
<h2>Manual Setup</h2>
149+
<!-- Step 2b: Manual Setup -->
150+
<div class="card disabled-section" id="manual-section">
151+
<h2>Step 2b: Manual Setup</h2>
139152
<p>If you already have an app registration, enter the details below.</p>
140153
<label style="margin-bottom: 0.25rem;">Tenant Access</label>
141154
<div class="toggle-group" id="manual-tenant-toggle">
@@ -205,15 +218,69 @@ function setTenantMode(section, isMulti) {
205218
if (setupState.isEasyAuthConfigured) {
206219
showBanner('Authentication is already configured. Redirecting...');
207220
setTimeout(() => window.location.href = '/', 2000);
221+
return;
208222
}
209223
if (!setupState.isRunningInAppService || !setupState.hasManagedIdentity) {
210224
showBanner('Warning: No managed identity detected. ARM self-configuration may fail. Use manual setup instead.');
211225
}
226+
227+
// Handle user table status
228+
const us = setupState.usersStatus;
229+
if (!us || !us.connected) {
230+
// Connection error — disable user section
231+
document.getElementById('btn-seed').disabled = true;
232+
showStatus('seed-status', 'Cannot connect to storage: ' + (us?.error || 'Unknown error'), 'error');
233+
} else if (us.hasUsers) {
234+
// Users already exist — skip to auth setup
235+
document.getElementById('user-section').classList.add('disabled-section');
236+
showStatus('seed-status', 'Users already exist in the table. Proceed to authentication setup below.', 'success');
237+
enableAuthSections();
238+
} else {
239+
// No users — enable the seed form, keep auth disabled
240+
document.getElementById('btn-seed').disabled = false;
241+
}
212242
} catch (e) {
213243
console.error('Failed to load status', e);
214244
}
215245
})();
216246
247+
function enableAuthSections() {
248+
document.getElementById('auto-section').classList.remove('disabled-section');
249+
document.getElementById('manual-section').classList.remove('disabled-section');
250+
}
251+
252+
async function seedFirstUser() {
253+
const upn = document.getElementById('seed-upn').value.trim();
254+
if (!upn) {
255+
showStatus('seed-status', 'Please enter a valid email address.', 'error');
256+
return;
257+
}
258+
259+
const btn = document.getElementById('btn-seed');
260+
btn.disabled = true;
261+
btn.textContent = 'Adding user...';
262+
showStatus('seed-status', 'Adding superadmin user...', 'info');
263+
264+
try {
265+
const res = await fetch('/api/setup/seed-user', {
266+
method: 'POST',
267+
headers: { 'Content-Type': 'application/json' },
268+
body: JSON.stringify({ upn })
269+
});
270+
271+
const data = await res.json();
272+
if (!res.ok || !data.success) throw new Error(data.message || 'Failed to add user');
273+
274+
showStatus('seed-status', data.message, 'success');
275+
document.getElementById('user-section').classList.add('disabled-section');
276+
enableAuthSections();
277+
} catch (e) {
278+
showStatus('seed-status', 'Error: ' + e.message, 'error');
279+
btn.disabled = false;
280+
btn.textContent = 'Add Superadmin';
281+
}
282+
}
283+
217284
function showBanner(msg) {
218285
const el = document.getElementById('status-banner');
219286
el.textContent = msg;
@@ -378,9 +445,11 @@ async function submitManual() {
378445
}
379446
function showRestartScreen() {
380447
// Hide setup sections, show restart polling UI
448+
document.getElementById('user-section').classList.add('hidden');
381449
document.getElementById('auto-section').classList.add('hidden');
382450
document.getElementById('manual-section').classList.add('hidden');
383-
document.querySelector('.divider').classList.add('hidden');
451+
document.getElementById('divider-user').classList.add('hidden');
452+
document.getElementById('divider-auth').classList.add('hidden');
384453
document.querySelector('.subtitle').textContent = '';
385454
document.getElementById('page-title').textContent = 'Restarting...';
386455

Services/SetupService.cs

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Security.Cryptography;
33
using System.Text;
44
using System.Text.Json;
5+
using Azure.Data.Tables;
56

67
namespace Craft.Services;
78

@@ -584,16 +585,120 @@ public async Task ConfigureManual(
584585
await ConfigureAppServiceAuth(appId, clientSecret, tenantId, multiTenant, ct);
585586
}
586587

588+
// ── First User Seeding ──
589+
590+
/// <summary>
591+
/// Resolves the storage connection string for the allowedUsers table.
592+
/// Same logic as AuthService — uses Auth.UserStorageConnection if set,
593+
/// falls back to AzureWebJobsStorage, then dev storage.
594+
/// </summary>
595+
private string StorageConnectionString =>
596+
(!string.IsNullOrEmpty(_settings.Auth.UserStorageConnection)
597+
? _settings.Auth.UserStorageConnection
598+
: Environment.GetEnvironmentVariable("AzureWebJobsStorage"))
599+
?? "UseDevelopmentStorage=true";
600+
601+
/// <summary>
602+
/// Resolves the user table name with the same sanitization as AuthService.
603+
/// </summary>
604+
private string ResolveUserTableName()
605+
{
606+
var raw = _settings.Auth.UserTableName;
607+
var sanitized = new string(raw.Where(char.IsLetterOrDigit).ToArray());
608+
if (sanitized.Length > 63) sanitized = sanitized[..63];
609+
if (sanitized.Length < 3) sanitized = "allowedUsers";
610+
return sanitized;
611+
}
612+
613+
/// <summary>
614+
/// Checks the allowedUsers table status: whether it's reachable and whether
615+
/// it already contains any users.
616+
/// </summary>
617+
public async Task<AllowedUsersStatus> CheckAllowedUsersStatus(CancellationToken ct = default)
618+
{
619+
try
620+
{
621+
var tableName = ResolveUserTableName();
622+
var client = new TableClient(StorageConnectionString, tableName);
623+
await client.CreateIfNotExistsAsync(cancellationToken: ct);
624+
625+
var count = 0;
626+
await foreach (var entity in client.QueryAsync<TableEntity>(cancellationToken: ct))
627+
{
628+
if (!entity.RowKey.StartsWith("_"))
629+
{
630+
count++;
631+
if (count > 0) break; // We only need to know if any exist
632+
}
633+
}
634+
635+
return new AllowedUsersStatus
636+
{
637+
Connected = true,
638+
HasUsers = count > 0
639+
};
640+
}
641+
catch (Exception ex)
642+
{
643+
_logger.LogWarning(ex, "[Setup] Failed to check allowedUsers table");
644+
return new AllowedUsersStatus
645+
{
646+
Connected = false,
647+
HasUsers = false,
648+
Error = ex.Message
649+
};
650+
}
651+
}
652+
653+
/// <summary>
654+
/// Seeds the first superadmin user into the allowedUsers table.
655+
/// Only works when the table is empty — refuses if users already exist.
656+
/// Uses the same entity schema as CIPP-API's Invoke-ExecCIPPUsers.
657+
/// </summary>
658+
public async Task SeedFirstUser(string upn, CancellationToken ct = default)
659+
{
660+
if (string.IsNullOrWhiteSpace(upn))
661+
throw new ArgumentException("UPN (email) is required.");
662+
663+
upn = upn.Trim().ToLower();
664+
665+
var tableName = ResolveUserTableName();
666+
var client = new TableClient(StorageConnectionString, tableName);
667+
await client.CreateIfNotExistsAsync(cancellationToken: ct);
668+
669+
// Guard: refuse if the table already has users
670+
await foreach (var entity in client.QueryAsync<TableEntity>(cancellationToken: ct))
671+
{
672+
if (!entity.RowKey.StartsWith("_"))
673+
throw new InvalidOperationException("The allowed users table already contains users. First-user seeding is only available on an empty table.");
674+
}
675+
676+
var roles = new[] { "superadmin" };
677+
var rolesJson = JsonSerializer.Serialize(roles);
678+
679+
var userEntity = new TableEntity("User", upn)
680+
{
681+
["Roles"] = rolesJson,
682+
["ManualRoles"] = rolesJson,
683+
["AutoRoles"] = "[]",
684+
["Source"] = "Manual"
685+
};
686+
687+
await client.UpsertEntityAsync(userEntity, TableUpdateMode.Replace, ct);
688+
_logger.LogInformation("[Setup] Seeded first superadmin user: {Upn}", upn);
689+
}
690+
587691
// ── Status ──
588692

589693
/// <summary>
590694
/// Returns setup status information.
591695
/// </summary>
592-
public SetupStatus GetStatus()
696+
public async Task<SetupStatus> GetStatus(CancellationToken ct = default)
593697
{
594698
var isConfigured = IsEasyAuthConfigured();
595699
var siteName = Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME");
596700
var hasManagedIdentity = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT"));
701+
var usersStatus = await CheckAllowedUsersStatus(ct);
597702

598703
return new SetupStatus
599704
{
@@ -602,7 +707,8 @@ public SetupStatus GetStatus()
602707
HasManagedIdentity = hasManagedIdentity,
603708
AppName = _settings.Name,
604709
AuthAppDisplayName = ResolveAuthAppDisplayName(),
605-
BootstrapClientId = _settings.Setup.BootstrapClientId
710+
BootstrapClientId = _settings.Setup.BootstrapClientId,
711+
UsersStatus = usersStatus
606712
};
607713
}
608714

@@ -836,6 +942,14 @@ public class SetupStatus
836942
public string AppName { get; set; } = "";
837943
public string AuthAppDisplayName { get; set; } = "";
838944
public string BootstrapClientId { get; set; } = "";
945+
public AllowedUsersStatus UsersStatus { get; set; } = new();
946+
}
947+
948+
public class AllowedUsersStatus
949+
{
950+
public bool Connected { get; set; }
951+
public bool HasUsers { get; set; }
952+
public string? Error { get; set; }
839953
}
840954

841955
public class DeviceCodeResponse

0 commit comments

Comments
 (0)