Skip to content

Commit 323fcb5

Browse files
committed
feat: support impersonation during development phase. This also removes the restriction on loops in script calls in development mode.
fixes #8
1 parent 194c011 commit 323fcb5

File tree

11 files changed

+151
-59
lines changed

11 files changed

+151
-59
lines changed

Catglobe.CgScript.Common/BaseCgScriptMaker.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,7 @@ protected async Task ProcessScriptReferences<T>(IScriptDefinition scriptDef, Str
6161
//add anything before the match to the sb
6262
finalScript.Append(rawScript.AsSpan(lastIdx, match.Index - lastIdx));
6363
//add the replacement to the sb
64-
finalScript.Append("new WorkflowScript(");
65-
var calledScriptName = match.Groups["scriptName"].Value;
66-
await processSingleReference(state, calledScriptName);
67-
finalScript.Append(')');
64+
await processSingleReference(state, match.Groups["scriptName"].Value);
6865
lastIdx = match.Index + match.Length;
6966
}
7067
//add rest

Catglobe.CgScript.Common/CgScriptMaker.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ public class CgScriptMaker(string environment, IReadOnlyDictionary<string, IScri
1616
///<inheritdoc/>
1717
protected override Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript) =>
1818
ProcessScriptReferences(scriptDef, finalScript, (_, calledScriptName) => {
19+
finalScript.Append("new WorkflowScript(");
1920
try
2021
{
2122
finalScript.Append(map.GetIdOf(calledScriptName));
2223
} catch (KeyNotFoundException)
2324
{
2425
throw new KeyNotFoundException($"Script '{scriptDef.ScriptName}' calls unknown script '{calledScriptName}'.");
2526
}
27+
finalScript.Append(')');
28+
2629
return Task.CompletedTask;
2730
}, new object());
2831
}

Catglobe.CgScript.Common/CgScriptMakerForDevelopment.cs

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,68 @@ namespace Catglobe.CgScript.Common;
66
/// Create a script from a script definition and a mapping
77
/// </summary>
88
/// <param name="definitions">List of scripts</param>
9-
public class CgScriptMakerForDevelopment(IReadOnlyDictionary<string, IScriptDefinition> definitions) : BaseCgScriptMaker("Development", definitions)
9+
/// <param name="impersonationMapping">Mapping of impersonations to development time users. 0 maps to developer account, missing maps to original</param>
10+
public class CgScriptMakerForDevelopment(IReadOnlyDictionary<string, IScriptDefinition> definitions, Dictionary<uint, uint>? impersonationMapping) : BaseCgScriptMaker("Development", definitions)
1011
{
1112
private readonly IReadOnlyDictionary<string, IScriptDefinition> _definitions = definitions;
13+
private readonly string _uniqueId = Guid.NewGuid().ToString("N");
1214

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

1618
///<inheritdoc/>
17-
protected override Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript)
19+
protected override async Task Generate(IScriptDefinition scriptDef, StringBuilder finalScript)
1820
{
19-
return ProcessScriptReferences(scriptDef, finalScript, ProcessSingleReference, new List<string>());
21+
//place to put all the called scripts
22+
var scriptDefs = new StringBuilder();
23+
var visited = new HashSet<IScriptDefinition>() {scriptDef};
24+
// process current script, which is going to make it a "clean" script
25+
await ProcessScriptReferences(scriptDef, finalScript, ProcessSingleReference, finalScript);
26+
//but we need that clean script as a string script to dynamically invoke it
27+
var outerScriptRef = GetScriptRef(scriptDef);
28+
ConvertScriptToStringScript(scriptDef, outerScriptRef, finalScript);
29+
//the whole script was moved to scriptDefs, so clear it and then re-add all definitions
30+
finalScript.Clear();
31+
finalScript.Append(scriptDefs);
32+
//and finally invoke the called script as if it was called
33+
finalScript.AppendLine($"{outerScriptRef}.Invoke(Workflow_getParameters());");
34+
return;
2035

21-
async Task ProcessSingleReference(List<string> visited, string calledScriptName)
36+
void ConvertScriptToStringScript(IScriptDefinition scriptDefinition, string name, StringBuilder stringBuilder)
2237
{
23-
if (visited.Contains(scriptDef.ScriptName)) throw new LoopDetectedException($"Loop detected while calling: {scriptDef.ScriptName}\nCall sequence:{string.Join(" - ", visited)}");
38+
stringBuilder.Replace(@"\", @"\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
39+
stringBuilder.Insert(0, $"WorkflowScript {name} = new WorkflowScript(\"");
40+
stringBuilder.AppendLine("\", false);");
41+
stringBuilder.AppendLine($"{name}.DynamicScriptName = \"{scriptDefinition.ScriptName}\";");
42+
stringBuilder.AppendLine($"Workflow_setGlobal(\"{name}\", {name});");
43+
if (scriptDefinition.Impersonation is { } imp)
44+
{
45+
impersonationMapping?.TryGetValue(imp, out imp);
46+
if (imp == 0)
47+
stringBuilder.AppendLine($"{name}.ImpersonatedUser = getCurrentUserUniqueId();");
48+
else
49+
stringBuilder.AppendLine($"{name}.ImpersonatedUser = {imp};");
50+
}
51+
scriptDefs.Append(stringBuilder);
2452

25-
finalScript.Append('"');
26-
var subSb = new StringBuilder();
53+
}
54+
55+
async Task ProcessSingleReference(StringBuilder curScript, string calledScriptName)
56+
{
2757
if (!_definitions.TryGetValue(calledScriptName, out var def)) throw new KeyNotFoundException($"Script '{scriptDef.ScriptName}' calls unknown script '{calledScriptName}'.");
28-
//we need to add to this one, otherwise 2 consecutive calls to same script would give the loop error when there is no loop
29-
var subVisited = new List<string>(visited) { scriptDef.ScriptName };
30-
await ProcessScriptReferences(def, subSb, ProcessSingleReference, subVisited);
31-
subSb.Replace(@"\", @"\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
32-
finalScript.Append(subSb);
33-
finalScript.Append("\", false");
58+
59+
var scriptRef = GetScriptRef(def);
60+
curScript.Append($"Workflow_getGlobal(\"{scriptRef}\")");
61+
62+
if (!visited.Add(def))
63+
return;
64+
65+
var subSb = new StringBuilder();
66+
await ProcessScriptReferences(def, subSb, ProcessSingleReference, subSb);
67+
ConvertScriptToStringScript(def, scriptRef, subSb);
3468
}
69+
70+
string GetScriptRef(IScriptDefinition scriptDefinition) => scriptDefinition.ScriptName.Replace("/", "__") + "__" + _uniqueId;
3571
}
3672
}
3773

38-
/// <summary>
39-
/// Thrown if a script ends up calling itself. This is only thrown in development mode
40-
/// </summary>
41-
public class LoopDetectedException(string message) : Exception(message);

Catglobe.CgScript.Common/CgScriptOptions.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ public class CgScriptOptions
1313
/// <summary>
1414
/// Which root folder are we running from
1515
/// </summary>
16-
public int FolderResourceId { get; set; }
16+
public uint FolderResourceId { get; set; }
1717

18+
/// <summary>
19+
/// For development, map these impersonations to these users instead. Use 0 to map to developer account
20+
/// </summary>
21+
public Dictionary<uint, uint>? ImpersonationMapping { get; set; }
1822
}

Catglobe.CgScript.Runtime/DevelopmentModeCgScriptApiClient.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@
44
using System.Text.Json.Serialization;
55
using System.Text.Json.Serialization.Metadata;
66
using Catglobe.CgScript.Common;
7+
using Microsoft.Extensions.Options;
78

89
namespace Catglobe.CgScript.Runtime;
910

10-
internal partial class DevelopmentModeCgScriptApiClient(HttpClient httpClient, IScriptProvider scriptProvider) : ApiClientBase(httpClient)
11+
internal partial class DevelopmentModeCgScriptApiClient(HttpClient httpClient, IScriptProvider scriptProvider, IOptions<CgScriptOptions> options) : ApiClientBase(httpClient)
1112
{
12-
IReadOnlyDictionary<string, IScriptDefinition>? _scriptDefinitions;
13-
private BaseCgScriptMaker? _cgScriptMaker;
13+
private IReadOnlyDictionary<string, IScriptDefinition>? _scriptDefinitions;
14+
private BaseCgScriptMaker? _cgScriptMaker;
1415
protected override async ValueTask<string> GetPath(string scriptName, string? additionalParameters = null)
1516
{
1617
if (_scriptDefinitions == null)
1718
{
1819
_scriptDefinitions = await scriptProvider.GetAll();
19-
_cgScriptMaker = new CgScriptMakerForDevelopment(_scriptDefinitions);
20+
_cgScriptMaker = new CgScriptMakerForDevelopment(_scriptDefinitions, options.Value.ImpersonationMapping);
2021
}
2122
return $"dynamicRun{additionalParameters ?? ""}";
2223
}

README.md

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,21 @@ Runtime requires the user to log in to the Catglobe site, and then the server wi
2121

2222
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.
2323
```cgscript
24-
number parentResourceId = 42; //for this library to work, this MUST be a folder
25-
string clientId = "some id, a guid works, but any string is acceptable"; //use your own id -> store this in appsettings.json
26-
bool canKeepSecret = true; //demo is a server app, so we can keep secrets
27-
string clientSecret = "secret";
28-
bool askUserForConsent = false;
29-
string layout = "";
30-
Array RedirectUri = {"https://staging.myapp.com/signin-oidc", "https://localhost:7176/signin-oidc"};
31-
Array PostLogoutRedirectUri = {"https://staging.myapp.com/signout-callback-oidc", "https://localhost:7176/signout-callback-oidc"};
32-
Array scopes = {"email", "profile", "roles", "openid", "offline_access"};
33-
Array optionalscopes = {};
34-
LocalizedString name = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US");
35-
36-
OidcAuthenticationFlow_createOrUpdate(parentResourceId, clientId, clientSecret, askUserForConsent,
37-
canKeepSecret, layout, RedirectUri, PostLogoutRedirectUri, scopes, optionalscopes, name);
24+
string clientSecret = User_generateRandomPassword(64);
25+
OidcAuthenticationFlow client = OidcAuthenticationFlow_createOrUpdate("some id, a guid works, but any string is acceptable");
26+
client.OwnerResourceId = 42; // for this library to work, this MUST be a folder
27+
client.CanKeepSecret = true; // demo is a server app, so we can keep secrets
28+
client.SetClientSecret(clientSecret);
29+
client.AskUserForConsent = false;
30+
client.Layout = "";
31+
client.RedirectUris = {"https://staging.myapp.com/signin-oidc", "https://localhost:7176/signin-oidc"};
32+
client.PostLogoutRedirectUris = {"https://staging.myapp.com/signout-callback-oidc", "https://localhost:7176/signout-callback-oidc"};
33+
client.Scopes = {"email", "profile", "roles", "openid", "offline_access"};
34+
client.OptionalScopes = {};
35+
client.DisplayNames = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US");
36+
client.Save();
37+
38+
print(clientSecret);
3839
```
3940

4041
Remember to set it up TWICE using 2 different `parentResourceId`, `clientId`!
@@ -101,6 +102,8 @@ services.AddAuthentication(SCHEMENAME)
101102
.AddOpenIdConnect(SCHEMENAME, oidcOptions => {
102103
builder.Configuration.GetSection(SCHEMENAME).Bind(oidcOptions);
103104
oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
105+
oidcOptions.TokenValidationParameters.NameClaimType = "name";
106+
oidcOptions.TokenValidationParameters.RoleClaimType = "cg_roles";
104107
})
105108
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
106109
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
162165
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.
163166
You should not adjust scope for this.
164167
```cgscript
165-
number parentResourceId = 42;
166-
string clientId = "DA431000-F318-4C55-9458-96A5D659866F"; //use your own id
167-
string clientSecret = "verysecret";
168-
number impersonationResourceId = User_getCurrentUser().ResourceId;
169-
Array scopes = {"scriptdeployment:w"};
170-
LocalizedString name = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US");
171-
OidcServer2ServerClient_createOrUpdate(parentResourceId, clientId, clientSecret, impersonationResourceId, scopes, name);
168+
string clientSecret = User_generateRandomPassword(64);
169+
OidcServer2ServerClient client = OidcServer2ServerClient_createOrUpdate("some id, a guid works, but any string is acceptable");
170+
client.OwnerResourceId = 42; // for this library to work, this MUST be a folder
171+
client.SetClientSecret(clientSecret);
172+
client.RunAsUserId = User_getCurrentUser().ResourceId;
173+
client.Scopes = {"scriptdeployment:w"};
174+
client.DisplayNames = new LocalizedString({"da-DK": "Min Demo App", "en-US": "My Demo App"}, "en-US");
175+
client.Save();
176+
177+
print(clientSecret);
172178
```
173179

174180
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())
202208
await app.Services.GetRequiredService<IDeployer>().Sync(app.Environment.EnvironmentName, default);
203209
```
204210

211+
# Apps that respondents needs to use
212+
213+
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.
214+
215+
```cgscript
216+
client.CanAuthRespondent = true;
217+
```
218+
```csharp
219+
services.AddAuthentication(SCHEMENAME)
220+
.AddOpenIdConnect(SCHEMENAME, oidcOptions => {
221+
builder.Configuration.GetSection(SCHEMENAME).Bind(oidcOptions);
222+
oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
223+
oidcOptions.TokenValidationParameters.NameClaimType = "name";
224+
oidcOptions.TokenValidationParameters.RoleClaimType = "cg_roles";
225+
226+
oidcOptions.Events.OnRedirectToIdentityProvider = context => {
227+
if (context.Properties.Items.TryGetValue("respondent", out var resp) &&
228+
context.Properties.Items.TryGetValue("respondent_secret", out var secret))
229+
{
230+
context.ProtocolMessage.Parameters["respondent"] = resp!;
231+
context.ProtocolMessage.Parameters["respondent_secret"] = secret!;
232+
}
233+
return Task.CompletedTask;
234+
};
235+
})
236+
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
237+
...
238+
group.MapGet("/login", (string? returnUrl, [FromQuery(Name="respondent")]string? respondent, [FromQuery(Name="respondent_secret")]string? secret) => {
239+
var authenticationProperties = GetAuthProperties(returnUrl);
240+
if (!string.IsNullOrEmpty(respondent) && !string.IsNullOrEmpty(secret))
241+
{
242+
authenticationProperties.Items["respondent"] = respondent;
243+
authenticationProperties.Items["respondent_secret"] = secret;
244+
}
245+
return TypedResults.Challenge(authenticationProperties);
246+
})
247+
.AllowAnonymous();
248+
```
249+
```cgscript
250+
//in gateway or qas dummy script
251+
252+
gotoUrl("https://siteurl.com/authentication/login?respondent=" + User_getCurrentUser().ResourceGuid + "&respondent_secret=" + qas.AccessCode);");
253+
```
254+
205255
# Usage of the library
206256

207257
## Development
@@ -212,7 +262,7 @@ At this stage the scripts are NOT synced to the server, but are instead dynamica
212262

213263
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).
214264

215-
All scripts are executed as the developer account and impersonation or public scripts are not supported!
265+
All scripts are executed as the developer account and public scripts are not supported without authentication!
216266

217267
If you have any public scripts, it is highly recommended you configure the entire site for authorization in development mode:
218268
```csharp
@@ -256,10 +306,6 @@ Since all scripts are dynamically generated during development, it also requires
256306

257307
See the example above on how to force the site to always force you to login after restart of site.
258308

259-
## impersonation is ignored during development
260-
261-
During development all scripts are executed as the developer account, therefore impersonation or public scripts are not supported!
262-
263309
## Where do I find the scopes that my site supports?
264310

265311
See supported scopes in your Catglobe site `https://mysite.catglobe.com/.well-known/openid-configuration` under `scopes_supported`.
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
string city = Workflow_getParameters()[0];
2-
3-
return User_getCurrentUser().Username + " says: It's too hot in " + city;
1+
//this is just fun to show we can also inline the call to the script
2+
new WorkflowScript("WeatherForecastHelpers/SummaryGenerator2").Invoke(Workflow_getParameters());
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
string city = Workflow_getParameters()[0];
2+
3+
return User_getCurrentUser().Username + " says: It's too hot in " + city;

demos/BlazorWebApp/BlazorWebApp/DemoUsage/SetupRuntime.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ public static void Configure(WebApplicationBuilder builder)
2121
// ........................................................................
2222
// The OIDC handler must use a sign-in scheme capable of persisting
2323
// user credentials across requests.
24-
oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
24+
oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
25+
oidcOptions.TokenValidationParameters.NameClaimType = "name";
26+
oidcOptions.TokenValidationParameters.RoleClaimType = "cg_roles";
2527
})
2628
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
2729

demos/BlazorWebApp/BlazorWebApp/appsettings.Development.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,10 @@
44
"Default": "Information",
55
"Microsoft.AspNetCore": "Warning"
66
}
7+
},
8+
"CatglobeApi": {
9+
"ImpersonationMapping": {
10+
"115": 0
11+
}
712
}
813
}

0 commit comments

Comments
 (0)