The Grouper custom template via GSH is in Grouper 2.5.43+

Intro

Custom templates via GSH are a function of Grouper that evolves the idea of an Access Management Platform.  You can make a UI screen or a web service call that is completely custom.  The inputs are dynamic, flexible, and have built-in validations.  The GSH script to process those inputs has full control of Grouper or Java.  There is security around who can run the template, and the user they run-as.  There are easy-to-use APIs to perform a list of actions.

Custom templates via GSH are good for:

  1. Performing actions that are not available in the UI
  2. Automating multiple tasks into one
  3. Creating a custom access management interface for a user to simplify actions
  4. Create a Web Service call that does not exist in Grouper
  5. Reduce time to perform tasks
  6. Reduce Help Desk Tickets  
  7. The sky is the limit

 

Reminder:  "Custom UI" and "Custom Template via GSH" are two different features in Grouper.

Use a Custom UI when you want an end-user facing UI where the end user can:

  • See their status and why (e.g. they might need a training or a different affiliation)
  • Can (optionally) enroll or unenroll easily into a service
  • Redirect based on certain conditions to other services
  • You don't want the end user to see the full Grouper UI


Use Grouper Custom Templates via GSH (the info on this page):

  • For people who use the Grouper UI
  • Can have custom inputs
  • Logic can use the input values and performance queries or tasks
  • Automate multiple tasks in one operation
  • Can expose via web service

Best practices

Script2.groovy: 603: illegal string body character after dollar sign;
   solution: either escape a literal dollar sign "\$5" or bracket the value expression "${5}" 
FROM: "$whatever"
TO: '$' + "whatever"
No signature of method: java.lang.String.positive() is applicable for argument types: () values: []
FROM: String something = ""
        + "hey";
TO: String something = "" +
        "hey";


Configuring

There is a wizard in the UI.  These are the configs that the wizard controls

grouper.properties

grouperGshTemplate.<configId>.suffix

config id suffixvalue exampledescriptionnotes
enabledtrue | falseif this template is enabledif false, do not show it in menu on folders or allow it to run
templateVersionV1 | V2

V1 is the legacy version, the script is in the gshTemplate

V2 is the newer way where you extend a class and can include tests


showOnGroupstrue | falseif this template option is available on groupsdefault false (TODO as of 2.5.36)
groupShowTypecertainGroup, groupsInFolder, allGroupsis this supposed to show for one group, groups in a certain folder, or allGroupsrequired, show if showOnGroups=true (TODO as of 2.5.36)
groupUuidToShowxyz321which group to show the template on.  could be uuid or name.required, show if groupShowType=certainGroup
groupShowOnDescendantsoneChildLevel, descendants

oneChildLevel: only show on groups directly in folder
descendants: show in all groups under the folder

required, show if groupShowType = groupsInfolder
showOnFolderstrue | falseif this template option is available on foldersdefault false
folderShowTypecertainFolder, allFolderswhere should this template be availabledrop down, required, show if showOnFolders = true
folderUuidToShowabc123uuid or name of folder to show this templateeventually we can have a folder combobox, currently textfield.  show if folderShowType = 'specifiedFolder' or groupShowType = 'groupsInFolder'.  Required
folderShowOnDescendantscertainFolder, oneChildLevel, certainFolderAndOneChildLevel, descendants, certainFolderAndDescendantscertainFolder: just show on one folder
oneChildLevel, only show on child level under folder
certainFolderAndOneChildLevel, show the folder and one level of children
descendants: show all folders under the folder
certainFolderAndDescendants: show folder and all descendants
if folderShowType = 'specifiedFolder'.  Required
runButtonGroupOrFoldergroup | folderIf you want a run button on the GSH template page, this will allow configuration of a default group or folder
defaultRunButtonGroupUuidOrNamea:b:cDefault group name or uuid for the run now button
defaultRunButtonFolderUuidOrNamea:b:cDefault folder name or uuid for the run now button
securityRunTypewheel | specifiedGroup | privilegeOnObject | everyonewho can run this template.  Only GrouperSystem / wheel group, or specify a group.  If privilegeOnObject, then check to see if user has certain privileges on the object where the template was invoked from.drop down, Required
groupUuidCanRundef456uuid or name of group that can run this template.  eventually we can have a group combobox, currently textfield.  Show if securityRunType = 'specifiedGroup'.  Required
requireFolderPrivilegeadmin, create, stemAttrRead, stemAttrUpdateIf running this template requires any of these privs on the folder it is run onshow if showOnFolders = true.  required if securityRunType = 'privilegeOnObject'
requireGroupPrivilegeadmin, read, update, read_and_update, optin, optout, view, groupAttrRead, groupAttrUpdatethe option to run the template will only show if the user has this privilege on the group at least (e.g. if the user has ADMIN they can READ)

show if showOnGroups = true.
required if securityRunType = 'privilegeOnObject'

runAsTypecurrentUser | GrouperSystem| specifiedSubjectselect the type of user to run as.  "currentUser" means
run as the user using the UI.  "GrouperSystem" means
run as root user, "specifiedSubject" means you can pick
a subject to run as (not common).
drop down.  Required
runAsSpecifiedSubjectSourceIdpennpersonselect the source ID of the specified subject to run asdrop down (subject source picker).  Required
runAsSpecifiedSubjectId12345678subject id of the specified subject to run aseventually we can have a subject combobox, currently textfield.  Required
templateNameExternalizedTextKeygrouperGshTemplate_<configId>_templateNameExternalizedTextKeythis is hardcoded for each templatethis is readonly, not editable, eventually we can have
an externalized text editor to edit that from this screen
templateDescriptionExternalizedTextKeygrouperGshTemplate_<configId>_templateDescriptionExternalizedTextKeythis is hardcoded for each templatethis is readonly
simplifiedUitrue | falseIf the UI should not show the normal Grouper menus, to not confuse users who are not familiar with Grouper
allowWsFromNoOwnertrue | falseif WS can call the template without identifying a stem or group owner
gshTemplate//this is the GSH template to runif we can get a textarea that would be good.  Should convert from windows or mac newlines to unix newlines on submit.  Required
numberOfInputs5number of inputs (form elements on ui or in ws)drop down from 0-50 repeat group.  Default value: 0
input.i.namegsh_input_folderNamename of the ui form element, ws param, template variable,validation: must start with gsh_input_, must be alphanumeric/underscore.  Required
input.i.labelExternalizedTextKeygrouperGshTemplate_<configId>_input_<inputName>_labelExternalizedTextKeyexternalized text key of the label on the UI for this inputreadonly
input.i.descriptionExternalizedTextKeygrouperGshTemplate_<configId>_input_<inputName>_descriptionExternalizedTextKeyexternalized text key of the label on the UI for this inputreadonly
input.i.typeint | boolean | stringtype of the datadrop down with supported types.  suggested starting point: int, boolean, string.  Default value: string
input.i.formElementTypetextfield, dropdown, checkboxform element type on UIshow if type is not equal to "boolean" (if boolean its a radio).  Default value: textfield.
input.i.index10form elements will show on the form in order of "index".  DefaultValue is 0.  If two elements have the same index then they will show in order of configuration.textfield
input.i.validationTyperegex | jexl | nonetype of validation on the inputrequired (none is not blank, it is an option)
input.i.validationRegex^[a-zA-Z0-9_]{1,50}$regex to check the input and if it doesnt match then fail.  For example, this is alphanumeric or underscore length between 1 and 50show if validationType = 'regex', required
input.i.validationJexl${gsh_input_myField.startsWith('whatever')}run a validation (and include all variables for cross-validations), return true for valid, and false for invalidshow if validationType = 'jexl', required
input.i.validationMessageExternalizedTextKeygrouperGshTemplate_<configId>_input_<inputName>_validationMessageExternalizedTextKeyreadonly key in externalized text for validation message (e.g. for jexl or regex)readonly
input.i.requiredtrue | falseif this input is required for template to rundefault value: false
input.i.defaultValueabcdefault value for the input if none is providedshow if required is false
input.i.showEl${gsh_input_someField = 'something'} for boolean, ${gsh_input_type == "Bulk"} for string value matching.jexl for if this field should show, note all inputs are available to use as variables
numberOfTests3number of tests to make sure this template functionsdrop down from 0-20 repeat group.  Default value: 0
test.i.deleteSideEffectsIfExistGshgsh scriptwill be run before and after test (first and last) to clean up what the test and setup and verification do.   Will not run after test if test failstextarea
test.i.setupGshgsh scriptwill be run before testtextarea
test.i.testGshgsh scriptcall the Java API for the template exec with inputstextarea, required
test.i.verifyGshgsh scriptverify that the test succeededtextarea

A grouper admin would configure this in the UI under miscellaneous

Text on screen similar to custom UI

This template could be called via ws REST JSON and pass in the input name/value form elements just like the UI would


Developing GSH templates with java

See this wiki as an example of developing GSH templates with java

Built in variables and inputs

Before the configured GSH script is executed, it will be prefixed with some built in variables, and the inputs from the user (supplied in the UI or less commonly the WS).

TypeVariableValue
GshTemplateOutputgsh_builtin_gshTemplateOutput Object that helps print output, notify about validation errors, or identify that an error occurred
GshTemplateRuntimegsh_builtin_gshTemplateRuntimeInternal object that is used to help with other variables
GrouperSessiongsh_builtin_grouperSessionSession based on who the template is running is (this is configured, recommended to be the user who is running the template)
Subjectgsh_builtin_subjectSubject who is running the template
Stringgsh_builtin_subjectIdSubject ID of gsh_builtin_subject
Stringgsh_builtin_ownerStemNameFolder name of the folder where the script is run (if stem script, otherwise null)
Stringgsh_builtin_ownerGroupNameGroup name for the group where the script was run (if group script, otherwise null)
Stringgsh_input_XXXXXX String inputs
Integergsh_input_YYYYYYYInteger inputs
Booleangsh_input_ZZZZZZZBoolean inputs


import edu.internet2.middleware.grouper.app.gsh.template.*;
import edu.internet2.middleware.grouper.util.*;
GshTemplateOutput gsh_builtin_gshTemplateOutput = GshTemplateOutput.retrieveGshTemplateOutput(); 
GshTemplateRuntime gsh_builtin_gshTemplateRuntime = GshTemplateRuntime.retrieveGshTemplateRuntime(); 
GrouperSession gsh_builtin_grouperSession = gsh_builtin_gshTemplateRuntime.getGrouperSession();
Subject gsh_builtin_subject = gsh_builtin_gshTemplateRuntime.getCurrentSubject();
String gsh_builtin_subjectId = \"subjectSubjectId\";
String gsh_builtin_ownerStemName = \"a:b:c\";
String gsh_input_workingGroupExtension = \"test\";
Integer gsh_input_theSize = 55;
Boolean gsh_input_isSympaModerated = false;
... the configured script ...


Check SQL for restricted value

Example of getting an input and checking a sql to see if its restricted

    int existingGroupCount = new GcDbAccess().connectionName("grouper").sql("select count(1) from grouper_groups gg where gg.name = ?").addBindVar(gsh_input_something).select(int.class); 
    
    if (existingGroupCount > 0) {
      gsh_builtin_gshTemplateOutput.addValidationLine("gsh_input_something", "Error: group exists");
    }

    // if anything not valid, stop
    if (GrouperUtil.length(gsh_builtin_gshTemplateOutput.getValidationLines()) > 0) {
      gsh_builtin_gshTemplateOutput.assignIsError(true);
      GrouperUtil.gshReturn();
    }


Check LDAP for restricted value

Example of getting an input and checking a ldap to see if its restricted

   List<String> eduPersonAffiliations = edu.internet2.middleware.grouper.ldap.LdapSessionUtils.ldapSession().list(
        String.class, "personLdap", "ou=People,dc=example,dc=edu", 
        edu.internet2.middleware.grouper.ldap.LdapSearchScope.SUBTREE_SCOPE, 
        "(&(uid=" + GrouperUtil.ldapFilterEscape(gsh_builtin_subject.getId()) + 
        ")(eduPersonAffiliation=" + GrouperUtil.ldapFilterEscape(gsh_input_something) + "))", "eduPersonAffiliation");

    if (GrouperUtil.length(eduPersonAffiliations) > 0) {
      gsh_builtin_gshTemplateOutput.addValidationLine("gsh_input_something", "Error: eduPersonAffiliation exists");
    }


    // if anything not valid, stop
    if (GrouperUtil.length(gsh_builtin_gshTemplateOutput.getValidationLines()) > 0) {
      gsh_builtin_gshTemplateOutput.assignIsError(true);
      GrouperUtil.gshReturn();
    }

Zoom add school example

Configuration

grouperGshTemplate.zoom.enabled = true
grouperGshTemplate.zoom.showOnFolders = true
grouperGshTemplate.zoom.folderShowType = certainFolder
grouperGshTemplate.zoom.folderShowOnDescendants = certainFolder
grouperGshTemplate.zoom.securityRunType = privilegeOnObject
grouperGshTemplate.zoom.requireFolderPrivilege = admin
grouperGshTemplate.zoom.runAsType = currentUser
grouperGshTemplate.zoom.gshTemplate = 

GrouperSession grouperSession = GrouperSession.startRootSession();
String prefix = Character.toUpperCase(gsh_input_orgName.charAt(0)) + gsh_input_orgName.substring(1,gsh_input_orgName.length());
String prefixLower = gsh_input_orgName;
Group excludeAdHocGroup = new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:service:ref:excludeAdHoc:" + prefixLower + "AdhocExcludeFromZoom").save();
Group excludeLoadedGroup = new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:service:ref:loadedGroupsForExclude:" + prefixLower + "ExcludeLoaded").save();
Group excludeGroup = new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:service:ref:excludeFromZoom:" + prefixLower + "ExcludeFromZoom").save();
Group excludedFromZoom = GroupFinder.findByName(grouperSession, "penn:isc:ait:apps:zoom:service:ref:usersExcludedFromZoom", true);
excludedFromZoom.addMember(excludeGroup.toSubject(), false);
Group schoolLspGroup = new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:security:schoolCenterAdminsAndLsps:zoom" + prefix + "Lsps").save();
Group lsps = GroupFinder.findByName(grouperSession, "penn:isc:ait:apps:zoom:security:zoomSchoolCenterLspsPreCheck", true);
lsps.addMember(schoolLspGroup.toSubject(), false);
Group schoolAdminGroup = new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:security:schoolCenterAdminsAndLsps:zoom" + prefix + "Admins").save();
Group admins = GroupFinder.findByName(grouperSession, "penn:isc:ait:apps:zoom:security:zoomSchoolCenterAdminsPreCheck", true);
admins.addMember(schoolAdminGroup.toSubject(), false);
if (gsh_input_addIncludes) {
  Group loadedGroup= new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:service:ref:loadedGroups:gsh_input_zoomGroupName).save();
  Group overrideGroup = new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:service:ref:groupsOverride:gsh_input_zoomGroupName).save();
  Group groupToGoToZoom = new GroupSave(grouperSession).assignName("penn:isc:ait:apps:zoom:service:policy:groupsToGoToZoom:gsh_input_zoomGroupName).save();
  Group zoomCanLogIn = GroupFinder.findByName(grouperSession, "penn:isc:ait:apps:zoom:service:policy:zoomCanLogIn", true);
  zoomCanLogIn.addMember(groupToGoToZoom.toSubject(), false);
}

grouperGshTemplate.zoom.numberOfInputs = 3
grouperGshTemplate.zoom.input.0.name = gsh_input_orgName
grouperGshTemplate.zoom.input.0.validationType = regex
grouperGshTemplate.zoom.input.0.validationRegex = ^[a-z][a-zA-Z0-9]{1,49}$
grouperGshTemplate.zoom.input.0.required = true
grouperGshTemplate.zoom.input.1.name = gsh_input_addIncludes
grouperGshTemplate.zoom.input.1.type = boolean
grouperGshTemplate.zoom.input.1.defaultValue = false
grouperGshTemplate.zoom.input.2.name = gsh_input_zoomGroupName
grouperGshTemplate.zoom.input.2.showEl = ${gsh_input_addIncludes}
grouperGshTemplate.zoom.input.2.required = true
grouperGshTemplate.zoom.input.2.validationType = regex
grouperGshTemplate.zoom.input.2.validationRegex = ^[a-zA-Z0-9_]{1,50}$


Form elements


grouper.text.en.us.properties

grouperGshTemplate_zoom_templateNameExternalizedTextKey = Add zoom school / center
grouperGshTemplate_zoom_templateDescriptionExternalizedTextKey = Add an organization to zoom.  If it is exclude only then do not select 'Add includes'.
grouperGshTemplate_zoom_input_gsh_input_orgName_labelExternalizedTextKey = School or center name
grouperGshTemplate_zoom_input_gsh_input_orgName_descriptionExternalizedTextKey = Enter a camel-cased school or center name, starting with lower case letter, can only contain alpha-numeric characters and must be less than 50 chars
grouperGshTemplate_zoom_input_gsh_input_orgName_validationMessageExternalizedTextKey = Invalid 'School or center name': must be start with lower case letter, can only contain alpha-numeric characters and must be less than 50 chars
grouperGshTemplate_zoom_input_gsh_input_addIncludes_labelExternalizedTextKey = Add 'includes'
grouperGshTemplate_zoom_input_gsh_input_addIncludes_descriptionExternalizedTextKey = If this school or center should have a group in Zoom and in "includes" group to sponsor accounts
grouperGshTemplate_zoom_input_gsh_input_zoomGroupName_labelExternalizedTextKey = Zoom group name
grouperGshTemplate_zoom_input_gsh_input_zoomGroupName_descriptionExternalizedTextKey = Group name in zoom for this population.  Must be alphanumeric or underscore and less than 50 chars
grouperGshTemplate_zoom_input_gsh_input_zoomGroupName_validationMessageExternalizedTextKey = Invalid 'Zoom group name' must be alphanumeric or underscore and less than 50 chars


Imagine an HTML form that has some inputs and a submit button
Form


Java API

TODO FIX THIS
GshTemplateOutput gshTemplateOutput = new GshTemplateExec()
.assignOwnerType(GshTemplateOwnerType.stem)
.assignOwnerStemName("a:b:c")
.addInput(new GshTemplateInput().assignName("gsh_input_something").assignValueString("hello"))
.addInput(new GshTemplateInput().assignName("gsh_input_something2").assignValueString("hello2"))
.exec();
System.out.println(gshTemplateOutput.isValid());
System.out.println(gshTemplateOutput.isSuccess());
for (GshOutputLine gshOutputLine : gshTemplateOutput.getOutputLines()) {
  System.out.println(gshOutputLine.getInputName() + ": " + gshOutputLine.getText());
}

Web service

Template "current user" is the web service "act as" user if it exists, or the authenticating user is not.

These are a PUT method since the templates should be written as idempotent.  If you have something that isn't idempotent that's ok, but aim for idempotency.  This means if someone calls the same template with same inputs twice the second call should be a NO-OP (do not do anything).  You can also use a POST for any call as well.

HTTP codeGrouper result codeMeaning
200SUCCESSValid and ran successfully
400INVALIDInvalid didnt run
500EXCEPTIONThrew exception

There are various ways to call the WS, see this page for the options

#########################################
##
## HTTP request sample (could be formatted for view by
## indenting or changing dates or other data)
##
#########################################


POST /grouper-ws/servicesRest/v2_5_000/gshTemplateExec HTTP/1.1
Connection: close
Authorization: Basic xxxxxxxxxxxxxxxxx==
User-Agent: Jakarta Commons-HttpClient/3.1
Host: localhost:8092
Content-Length: 181
Content-Type: application/json; charset=UTF-8

{
  "WsRestGshTemplateExecRequest":{
    "gshTemplateActAsSubjectLookup": {
      "subjectSourceId":"ldap",
      "subjectId":"eisbruch@at.internet2.edu"
    },
    "ownerStemLookup":{
      "stemName":"test2"
    },
    "ownerType":"stem",
    "configId":"testGshTemplateConfig",
    
    // new in v4.9.4+ and v5.6.1+. arbitrary input based on the template and its needs
    // this is marshaled into a Map which can be converted into a Javabean
    "wsInput": {"gsh_input_prefix":"TEST"}
  }
}


#########################################
##
## HTTP response sample (could be formatted for view by
## indenting or changing dates or other data)
##
#########################################


HTTP/1.1 200
Set-Cookie: JSESSIONID=1197432C8BE84B39F70438D3EB93E014;path=/grouper-ws/;HttpOnly
X-Grouper-resultCode: SUCCESS
X-Grouper-success: T
X-Grouper-resultCode2: NONE
Content-Type: application/json;charset=UTF-8
Content-Length: 4699
Date: Sun, 28 Feb 2021 02:30:15 GMT
Connection: close
Server: Apache TomEE

{
  "WsGshTemplateExecResult":{
    "gshOutputLines":[
    ]
    ,
    
    // wsOutput is new in v4.10.0+ and v5.7.0+. arbitrary output based 
    // on the template and its needs (assign a Map or Javabean)
    "wsOutput": {
      "totalMembershipCount": 12,
      "immediateMembershipCount": 10
    }, 
    
    "gshScriptOutput":"",
    "gshValidationLines":[
    ]
    , 
    "responseMetadata":{
      "millis":"20197",
      "serverVersion":"2.5.0"
    },
    "resultMetadata":{
      "resultCode":"SUCCESS",
      "resultMessage":"Success for: clientVersion: 2.5.0, configId: testGshTemplateConfig, ownerType: stem , inputs: Array size: 1: [0]: edu.internet2.middleware.grouper.ws.coresoap.WsGshTemplateInput@52f79ba1\n\n, actAsSubject: null, paramNames: \n, params: null",
      "success":"T"
    },
    "transaction":true
  }
}