Skip to content

Commit

Permalink
feat: support impersonation during development phase. This also remov…
Browse files Browse the repository at this point in the history
…es the restriction on loops in script calls in development mode.

fixes #8
  • Loading branch information
davhdavh committed Dec 10, 2024
1 parent 194c011 commit 323fcb5
Show file tree
Hide file tree
Showing 11 changed files with 151 additions and 59 deletions.
5 changes: 1 addition & 4 deletions Catglobe.CgScript.Common/BaseCgScriptMaker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,7 @@ protected async Task ProcessScriptReferences<T>(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
Expand Down
3 changes: 3 additions & 0 deletions Catglobe.CgScript.Common/CgScriptMaker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ public class CgScriptMaker(string environment, IReadOnlyDictionary<string, IScri
///<inheritdoc/>
protected override Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript) =>
ProcessScriptReferences(scriptDef, finalScript, (_, calledScriptName) => {
finalScript.Append("new WorkflowScript(");
try
{
finalScript.Append(map.GetIdOf(calledScriptName));
} catch (KeyNotFoundException)
{
throw new KeyNotFoundException($"Script '{scriptDef.ScriptName}' calls unknown script '{calledScriptName}'.");
}
finalScript.Append(')');

return Task.CompletedTask;
}, new object());
}
Expand Down
66 changes: 49 additions & 17 deletions Catglobe.CgScript.Common/CgScriptMakerForDevelopment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,68 @@ namespace Catglobe.CgScript.Common;
/// Create a script from a script definition and a mapping
/// </summary>
/// <param name="definitions">List of scripts</param>
public class CgScriptMakerForDevelopment(IReadOnlyDictionary<string, IScriptDefinition> definitions) : BaseCgScriptMaker("Development", definitions)
/// <param name="impersonationMapping">Mapping of impersonations to development time users. 0 maps to developer account, missing maps to original</param>
public class CgScriptMakerForDevelopment(IReadOnlyDictionary<string, IScriptDefinition> definitions, Dictionary<uint, uint>? impersonationMapping) : BaseCgScriptMaker("Development", definitions)
{
private readonly IReadOnlyDictionary<string, IScriptDefinition> _definitions = definitions;
private readonly string _uniqueId = Guid.NewGuid().ToString("N");

///<inheritdoc/>
protected override string GetPreamble(IScriptDefinition scriptDef) => "";

///<inheritdoc/>
protected override Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript)
protected override async Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript)
{
return ProcessScriptReferences(scriptDef, finalScript, ProcessSingleReference, new List<string>());
//place to put all the called scripts
var scriptDefs = new StringBuilder();
var visited = new HashSet<IScriptDefinition>() {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<string> 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<string>(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;
}
}

/// <summary>
/// Thrown if a script ends up calling itself. This is only thrown in development mode
/// </summary>
public class LoopDetectedException(string message) : Exception(message);
6 changes: 5 additions & 1 deletion Catglobe.CgScript.Common/CgScriptOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ public class CgScriptOptions
/// <summary>
/// Which root folder are we running from
/// </summary>
public int FolderResourceId { get; set; }
public uint FolderResourceId { get; set; }

/// <summary>
/// For development, map these impersonations to these users instead. Use 0 to map to developer account
/// </summary>
public Dictionary<uint, uint>? ImpersonationMapping { get; set; }
}
9 changes: 5 additions & 4 deletions Catglobe.CgScript.Runtime/DevelopmentModeCgScriptApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CgScriptOptions> options) : ApiClientBase(httpClient)
{
IReadOnlyDictionary<string, IScriptDefinition>? _scriptDefinitions;
private BaseCgScriptMaker? _cgScriptMaker;
private IReadOnlyDictionary<string, IScriptDefinition>? _scriptDefinitions;
private BaseCgScriptMaker? _cgScriptMaker;
protected override async ValueTask<string> 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 ?? ""}";
}
Expand Down
98 changes: 72 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`!
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -202,6 +208,50 @@ if (!app.Environment.IsDevelopment())
await app.Services.GetRequiredService<IDeployer>().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
Expand All @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
string city = Workflow_getParameters()[0];

return User_getCurrentUser().Username + " says: It's too hot in " + city;
4 changes: 3 additions & 1 deletion demos/BlazorWebApp/BlazorWebApp/DemoUsage/SetupRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions demos/BlazorWebApp/BlazorWebApp/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"CatglobeApi": {
"ImpersonationMapping": {
"115": 0
}
}
}
6 changes: 3 additions & 3 deletions demos/BlazorWebApp/BlazorWebApp/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down

0 comments on commit 323fcb5

Please sign in to comment.