From 323fcb53264b019f1f78f29fdd4430020f27f163 Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Tue, 10 Dec 2024 16:45:40 +0700 Subject: [PATCH] feat: support impersonation during development phase. This also removes the restriction on loops in script calls in development mode. fixes #8 --- Catglobe.CgScript.Common/BaseCgScriptMaker.cs | 5 +- Catglobe.CgScript.Common/CgScriptMaker.cs | 3 + .../CgScriptMakerForDevelopment.cs | 66 +++++++++---- Catglobe.CgScript.Common/CgScriptOptions.cs | 6 +- .../DevelopmentModeCgScriptApiClient.cs | 9 +- README.md | 98 ++++++++++++++----- .../SummaryGenerator.cgs | 5 +- .../SummaryGenerator2.cgs | 3 + .../BlazorWebApp/DemoUsage/SetupRuntime.cs | 4 +- .../BlazorWebApp/appsettings.Development.json | 5 + .../BlazorWebApp/appsettings.json | 6 +- 11 files changed, 151 insertions(+), 59 deletions(-) create mode 100644 demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator2.cgs diff --git a/Catglobe.CgScript.Common/BaseCgScriptMaker.cs b/Catglobe.CgScript.Common/BaseCgScriptMaker.cs index 4aefb28..20055f3 100644 --- a/Catglobe.CgScript.Common/BaseCgScriptMaker.cs +++ b/Catglobe.CgScript.Common/BaseCgScriptMaker.cs @@ -61,10 +61,7 @@ protected async Task ProcessScriptReferences(IScriptDefinition scriptDef, Str //add anything before the match to the sb finalScript.Append(rawScript.AsSpan(lastIdx, match.Index - lastIdx)); //add the replacement to the sb - finalScript.Append("new WorkflowScript("); - var calledScriptName = match.Groups["scriptName"].Value; - await processSingleReference(state, calledScriptName); - finalScript.Append(')'); + await processSingleReference(state, match.Groups["scriptName"].Value); lastIdx = match.Index + match.Length; } //add rest diff --git a/Catglobe.CgScript.Common/CgScriptMaker.cs b/Catglobe.CgScript.Common/CgScriptMaker.cs index 3eadddf..4950506 100644 --- a/Catglobe.CgScript.Common/CgScriptMaker.cs +++ b/Catglobe.CgScript.Common/CgScriptMaker.cs @@ -16,6 +16,7 @@ public class CgScriptMaker(string environment, IReadOnlyDictionary protected override Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript) => ProcessScriptReferences(scriptDef, finalScript, (_, calledScriptName) => { + finalScript.Append("new WorkflowScript("); try { finalScript.Append(map.GetIdOf(calledScriptName)); @@ -23,6 +24,8 @@ protected override Task Generate(IScriptDefinition scriptDef, StringBuilder fina { throw new KeyNotFoundException($"Script '{scriptDef.ScriptName}' calls unknown script '{calledScriptName}'."); } + finalScript.Append(')'); + return Task.CompletedTask; }, new object()); } diff --git a/Catglobe.CgScript.Common/CgScriptMakerForDevelopment.cs b/Catglobe.CgScript.Common/CgScriptMakerForDevelopment.cs index db919bd..d0370a4 100644 --- a/Catglobe.CgScript.Common/CgScriptMakerForDevelopment.cs +++ b/Catglobe.CgScript.Common/CgScriptMakerForDevelopment.cs @@ -6,36 +6,68 @@ namespace Catglobe.CgScript.Common; /// Create a script from a script definition and a mapping /// /// List of scripts -public class CgScriptMakerForDevelopment(IReadOnlyDictionary definitions) : BaseCgScriptMaker("Development", definitions) +/// Mapping of impersonations to development time users. 0 maps to developer account, missing maps to original +public class CgScriptMakerForDevelopment(IReadOnlyDictionary definitions, Dictionary? impersonationMapping) : BaseCgScriptMaker("Development", definitions) { private readonly IReadOnlyDictionary _definitions = definitions; + private readonly string _uniqueId = Guid.NewGuid().ToString("N"); /// protected override string GetPreamble(IScriptDefinition scriptDef) => ""; /// - protected override Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript) + protected override async Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript) { - return ProcessScriptReferences(scriptDef, finalScript, ProcessSingleReference, new List()); + //place to put all the called scripts + var scriptDefs = new StringBuilder(); + var visited = new HashSet() {scriptDef}; + // process current script, which is going to make it a "clean" script + await ProcessScriptReferences(scriptDef, finalScript, ProcessSingleReference, finalScript); + //but we need that clean script as a string script to dynamically invoke it + var outerScriptRef = GetScriptRef(scriptDef); + ConvertScriptToStringScript(scriptDef, outerScriptRef, finalScript); + //the whole script was moved to scriptDefs, so clear it and then re-add all definitions + finalScript.Clear(); + finalScript.Append(scriptDefs); + //and finally invoke the called script as if it was called + finalScript.AppendLine($"{outerScriptRef}.Invoke(Workflow_getParameters());"); + return; - async Task ProcessSingleReference(List visited, string calledScriptName) + void ConvertScriptToStringScript(IScriptDefinition scriptDefinition, string name, StringBuilder stringBuilder) { - if (visited.Contains(scriptDef.ScriptName)) throw new LoopDetectedException($"Loop detected while calling: {scriptDef.ScriptName}\nCall sequence:{string.Join(" - ", visited)}"); + stringBuilder.Replace(@"\", @"\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); + stringBuilder.Insert(0, $"WorkflowScript {name} = new WorkflowScript(\""); + stringBuilder.AppendLine("\", false);"); + stringBuilder.AppendLine($"{name}.DynamicScriptName = \"{scriptDefinition.ScriptName}\";"); + stringBuilder.AppendLine($"Workflow_setGlobal(\"{name}\", {name});"); + if (scriptDefinition.Impersonation is { } imp) + { + impersonationMapping?.TryGetValue(imp, out imp); + if (imp == 0) + stringBuilder.AppendLine($"{name}.ImpersonatedUser = getCurrentUserUniqueId();"); + else + stringBuilder.AppendLine($"{name}.ImpersonatedUser = {imp};"); + } + scriptDefs.Append(stringBuilder); - finalScript.Append('"'); - var subSb = new StringBuilder(); + } + + async Task ProcessSingleReference(StringBuilder curScript, string calledScriptName) + { if (!_definitions.TryGetValue(calledScriptName, out var def)) throw new KeyNotFoundException($"Script '{scriptDef.ScriptName}' calls unknown script '{calledScriptName}'."); - //we need to add to this one, otherwise 2 consecutive calls to same script would give the loop error when there is no loop - var subVisited = new List(visited) { scriptDef.ScriptName }; - await ProcessScriptReferences(def, subSb, ProcessSingleReference, subVisited); - subSb.Replace(@"\", @"\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); - finalScript.Append(subSb); - finalScript.Append("\", false"); + + var scriptRef = GetScriptRef(def); + curScript.Append($"Workflow_getGlobal(\"{scriptRef}\")"); + + if (!visited.Add(def)) + return; + + var subSb = new StringBuilder(); + await ProcessScriptReferences(def, subSb, ProcessSingleReference, subSb); + ConvertScriptToStringScript(def, scriptRef, subSb); } + + string GetScriptRef(IScriptDefinition scriptDefinition) => scriptDefinition.ScriptName.Replace("/", "__") + "__" + _uniqueId; } } -/// -/// Thrown if a script ends up calling itself. This is only thrown in development mode -/// -public class LoopDetectedException(string message) : Exception(message); diff --git a/Catglobe.CgScript.Common/CgScriptOptions.cs b/Catglobe.CgScript.Common/CgScriptOptions.cs index e9de145..08f3a6d 100644 --- a/Catglobe.CgScript.Common/CgScriptOptions.cs +++ b/Catglobe.CgScript.Common/CgScriptOptions.cs @@ -13,6 +13,10 @@ public class CgScriptOptions /// /// Which root folder are we running from /// - public int FolderResourceId { get; set; } + public uint FolderResourceId { get; set; } + /// + /// For development, map these impersonations to these users instead. Use 0 to map to developer account + /// + public Dictionary? ImpersonationMapping { get; set; } } diff --git a/Catglobe.CgScript.Runtime/DevelopmentModeCgScriptApiClient.cs b/Catglobe.CgScript.Runtime/DevelopmentModeCgScriptApiClient.cs index ddcbcaa..0313168 100644 --- a/Catglobe.CgScript.Runtime/DevelopmentModeCgScriptApiClient.cs +++ b/Catglobe.CgScript.Runtime/DevelopmentModeCgScriptApiClient.cs @@ -4,19 +4,20 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Catglobe.CgScript.Common; +using Microsoft.Extensions.Options; namespace Catglobe.CgScript.Runtime; -internal partial class DevelopmentModeCgScriptApiClient(HttpClient httpClient, IScriptProvider scriptProvider) : ApiClientBase(httpClient) +internal partial class DevelopmentModeCgScriptApiClient(HttpClient httpClient, IScriptProvider scriptProvider, IOptions options) : ApiClientBase(httpClient) { - IReadOnlyDictionary? _scriptDefinitions; - private BaseCgScriptMaker? _cgScriptMaker; + private IReadOnlyDictionary? _scriptDefinitions; + private BaseCgScriptMaker? _cgScriptMaker; protected override async ValueTask GetPath(string scriptName, string? additionalParameters = null) { if (_scriptDefinitions == null) { _scriptDefinitions = await scriptProvider.GetAll(); - _cgScriptMaker = new CgScriptMakerForDevelopment(_scriptDefinitions); + _cgScriptMaker = new CgScriptMakerForDevelopment(_scriptDefinitions, options.Value.ImpersonationMapping); } return $"dynamicRun{additionalParameters ?? ""}"; } diff --git a/README.md b/README.md index bc49f63..0f3df81 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,21 @@ Runtime requires the user to log in to the Catglobe site, and then the server wi Adjust the following cgscript with the parentResourceId, clientId, clientSecret and name of the client and the requested scopes for your purpose and execute it on your Catglobe site. ```cgscript -number parentResourceId = 42; //for this library to work, this MUST be a folder -string clientId = "some id, a guid works, but any string is acceptable"; //use your own id -> store this in appsettings.json -bool canKeepSecret = true; //demo is a server app, so we can keep secrets -string clientSecret = "secret"; -bool askUserForConsent = false; -string layout = ""; -Array RedirectUri = {"https://staging.myapp.com/signin-oidc", "https://localhost:7176/signin-oidc"}; -Array PostLogoutRedirectUri = {"https://staging.myapp.com/signout-callback-oidc", "https://localhost:7176/signout-callback-oidc"}; -Array scopes = {"email", "profile", "roles", "openid", "offline_access"}; -Array optionalscopes = {}; -LocalizedString name = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US"); - -OidcAuthenticationFlow_createOrUpdate(parentResourceId, clientId, clientSecret, askUserForConsent, - canKeepSecret, layout, RedirectUri, PostLogoutRedirectUri, scopes, optionalscopes, name); +string clientSecret = User_generateRandomPassword(64); +OidcAuthenticationFlow client = OidcAuthenticationFlow_createOrUpdate("some id, a guid works, but any string is acceptable"); +client.OwnerResourceId = 42; // for this library to work, this MUST be a folder +client.CanKeepSecret = true; // demo is a server app, so we can keep secrets +client.SetClientSecret(clientSecret); +client.AskUserForConsent = false; +client.Layout = ""; +client.RedirectUris = {"https://staging.myapp.com/signin-oidc", "https://localhost:7176/signin-oidc"}; +client.PostLogoutRedirectUris = {"https://staging.myapp.com/signout-callback-oidc", "https://localhost:7176/signout-callback-oidc"}; +client.Scopes = {"email", "profile", "roles", "openid", "offline_access"}; +client.OptionalScopes = {}; +client.DisplayNames = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US"); +client.Save(); + +print(clientSecret); ``` Remember to set it up TWICE using 2 different `parentResourceId`, `clientId`! @@ -101,6 +102,8 @@ services.AddAuthentication(SCHEMENAME) .AddOpenIdConnect(SCHEMENAME, oidcOptions => { builder.Configuration.GetSection(SCHEMENAME).Bind(oidcOptions); oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + oidcOptions.TokenValidationParameters.NameClaimType = "name"; + oidcOptions.TokenValidationParameters.RoleClaimType = "cg_roles"; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme); services.AddCgScript(builder.Configuration.GetSection("CatglobeApi"), builder.Environment.IsDevelopment()); @@ -162,13 +165,16 @@ This app does NOT need to be a asp.net app, it can be a console app. e.g. if you Adjust the following cgscript with the impersonationResourceId, parentResourceId, clientId, clientSecret and name of the client for your purpose and execute it on your Catglobe site. You should not adjust scope for this. ```cgscript -number parentResourceId = 42; -string clientId = "DA431000-F318-4C55-9458-96A5D659866F"; //use your own id -string clientSecret = "verysecret"; -number impersonationResourceId = User_getCurrentUser().ResourceId; -Array scopes = {"scriptdeployment:w"}; -LocalizedString name = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US"); -OidcServer2ServerClient_createOrUpdate(parentResourceId, clientId, clientSecret, impersonationResourceId, scopes, name); +string clientSecret = User_generateRandomPassword(64); +OidcServer2ServerClient client = OidcServer2ServerClient_createOrUpdate("some id, a guid works, but any string is acceptable"); +client.OwnerResourceId = 42; // for this library to work, this MUST be a folder +client.SetClientSecret(clientSecret); +client.RunAsUserId = User_getCurrentUser().ResourceId; +client.Scopes = {"scriptdeployment:w"}; +client.DisplayNames = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US"); +client.Save(); + +print(clientSecret); ``` Remember to set it up TWICE using 2 different `parentResourceId` and `ClientId`! Once for the production site and once for the staging site. @@ -202,6 +208,50 @@ if (!app.Environment.IsDevelopment()) await app.Services.GetRequiredService().Sync(app.Environment.EnvironmentName, default); ``` +# Apps that respondents needs to use + +If you have an app that respondents needs to use, you can use the following code to make sure that the user is authenticated via a qas, so they can use the app without additional authentication. + +```cgscript +client.CanAuthRespondent = true; +``` +```csharp +services.AddAuthentication(SCHEMENAME) + .AddOpenIdConnect(SCHEMENAME, oidcOptions => { + builder.Configuration.GetSection(SCHEMENAME).Bind(oidcOptions); + oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + oidcOptions.TokenValidationParameters.NameClaimType = "name"; + oidcOptions.TokenValidationParameters.RoleClaimType = "cg_roles"; + + oidcOptions.Events.OnRedirectToIdentityProvider = context => { + if (context.Properties.Items.TryGetValue("respondent", out var resp) && + context.Properties.Items.TryGetValue("respondent_secret", out var secret)) + { + context.ProtocolMessage.Parameters["respondent"] = resp!; + context.ProtocolMessage.Parameters["respondent_secret"] = secret!; + } + return Task.CompletedTask; + }; + }) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme); +... + group.MapGet("/login", (string? returnUrl, [FromQuery(Name="respondent")]string? respondent, [FromQuery(Name="respondent_secret")]string? secret) => { + var authenticationProperties = GetAuthProperties(returnUrl); + if (!string.IsNullOrEmpty(respondent) && !string.IsNullOrEmpty(secret)) + { + authenticationProperties.Items["respondent"] = respondent; + authenticationProperties.Items["respondent_secret"] = secret; + } + return TypedResults.Challenge(authenticationProperties); + }) + .AllowAnonymous(); +``` +```cgscript +//in gateway or qas dummy script + +gotoUrl("https://siteurl.com/authentication/login?respondent=" + User_getCurrentUser().ResourceGuid + "&respondent_secret=" + qas.AccessCode);"); +``` + # Usage of the library ## Development @@ -212,7 +262,7 @@ At this stage the scripts are NOT synced to the server, but are instead dynamica The authentication model is therefore that the developer logs into the using his own personal account. This account needs to have the questionnaire script dynamic execution access (plus any access required by the script). -All scripts are executed as the developer account and impersonation or public scripts are not supported! +All scripts are executed as the developer account and public scripts are not supported without authentication! If you have any public scripts, it is highly recommended you configure the entire site for authorization in development mode: ```csharp @@ -256,10 +306,6 @@ Since all scripts are dynamically generated during development, it also requires See the example above on how to force the site to always force you to login after restart of site. -## impersonation is ignored during development - -During development all scripts are executed as the developer account, therefore impersonation or public scripts are not supported! - ## Where do I find the scopes that my site supports? See supported scopes in your Catglobe site `https://mysite.catglobe.com/.well-known/openid-configuration` under `scopes_supported`. diff --git a/demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator.cgs b/demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator.cgs index 33067d7..9130817 100644 --- a/demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator.cgs +++ b/demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator.cgs @@ -1,3 +1,2 @@ -string city = Workflow_getParameters()[0]; - -return User_getCurrentUser().Username + " says: It's too hot in " + city; +//this is just fun to show we can also inline the call to the script +new WorkflowScript("WeatherForecastHelpers/SummaryGenerator2").Invoke(Workflow_getParameters()); diff --git a/demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator2.cgs b/demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator2.cgs new file mode 100644 index 0000000..33067d7 --- /dev/null +++ b/demos/BlazorWebApp/BlazorWebApp/CgScript/WeatherForecastHelpers/SummaryGenerator2.cgs @@ -0,0 +1,3 @@ +string city = Workflow_getParameters()[0]; + +return User_getCurrentUser().Username + " says: It's too hot in " + city; diff --git a/demos/BlazorWebApp/BlazorWebApp/DemoUsage/SetupRuntime.cs b/demos/BlazorWebApp/BlazorWebApp/DemoUsage/SetupRuntime.cs index 29bdb68..3f6e822 100644 --- a/demos/BlazorWebApp/BlazorWebApp/DemoUsage/SetupRuntime.cs +++ b/demos/BlazorWebApp/BlazorWebApp/DemoUsage/SetupRuntime.cs @@ -21,7 +21,9 @@ public static void Configure(WebApplicationBuilder builder) // ........................................................................ // The OIDC handler must use a sign-in scheme capable of persisting // user credentials across requests. - oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + oidcOptions.TokenValidationParameters.NameClaimType = "name"; + oidcOptions.TokenValidationParameters.RoleClaimType = "cg_roles"; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme); diff --git a/demos/BlazorWebApp/BlazorWebApp/appsettings.Development.json b/demos/BlazorWebApp/BlazorWebApp/appsettings.Development.json index 0c208ae..2fd8cdb 100644 --- a/demos/BlazorWebApp/BlazorWebApp/appsettings.Development.json +++ b/demos/BlazorWebApp/BlazorWebApp/appsettings.Development.json @@ -4,5 +4,10 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "CatglobeApi": { + "ImpersonationMapping": { + "115": 0 + } } } diff --git a/demos/BlazorWebApp/BlazorWebApp/appsettings.json b/demos/BlazorWebApp/BlazorWebApp/appsettings.json index 1cde29b..665e01b 100644 --- a/demos/BlazorWebApp/BlazorWebApp/appsettings.json +++ b/demos/BlazorWebApp/BlazorWebApp/appsettings.json @@ -10,7 +10,7 @@ }, "AllowedHosts": "*", "CatglobeOidc": { - "Authority": "https://testme.catglobe.com/", + "Authority": "https://localhost:5001/", "ClientId": "13BAC6C1-8DEC-46E2-B378-90E0325F8132", "ClientSecret": "secret", "ResponseType": "code", @@ -19,10 +19,10 @@ }, "CatglobeApi": { "FolderResourceId": 19705454, - "Site": "https://testme.catglobe.com/" + "Site": "https://localhost:5001/" }, "CatglobeDeployment": { - "Authority": "https://testme.catglobe.com/", + "Authority": "https://localhost:5001/", "ClientId": "DA431000-F318-4C55-9458-96A5D659866F", "ClientSecret": "verysecret", "FolderResourceId": 19705454,