New tool: Login Audit Log GUI (Appian interface)

Certified Lead Developer

Hey all!

I'm sharing the Appian interface I recently built to give better visibility into the system's recent internal Login Audit files (instead of constantly downloading and opening CSV files) - particularly helpful if you're an O&M user and/or diagnosing login issues for any other reason.

This interface relies on the Log Reader plug-in (and also uses the Date and Time Utilites plug-in for a certain transform on timestamps).  That said, I can't say how it'll perform on multi-server environments, since I don't have one to try it on.

The entire interface is self-contained, with the sole exception of a custom rule I wrote that utilizes the internal "User" record to take an entered username and get the system's properly-cased username (since there is some inconsistent case sensitivity around usernames and there's no other way I could come up with to tell what the 'real' version is).  I'll include that expression rule code after the main interface code.

a!localVariables(
  local!relevantDay: today(),
  local!isViewingToday: local!relevantDay = today(),

  local!dayString: if(
    local!isViewingToday,
    "",
    "." & text(local!relevantDay, "yyyy-mm-dd")
  ),
  local!refreshCounter: 0,
  
  /* use this (optionally) to filter out a service account that might get a disproportionate number of hits per day.  if none, set it to a nonsense string. */
  local!usernameToFilterOut: "api.test.account.name.asdf",

  local!currentLog: a!refreshVariable(
    value: readcsvlogpagingwithheaders(
      csvPath: "login-audit.csv" & local!dayString,
      startIndex: 1,
      batchSize: 1000,
      headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "secondaryUuid"},
      filterColumName: "username",
      filterOperator: "!=",
      filterValue: local!usernameToFilterOut
    ),
    refreshOnVarChange: {local!refreshCounter}
  ),
  local!backgroundAutoRefreshedTotalCount: a!refreshVariable(
    value: if(
      local!isViewingToday,
      readcsvlogpagingwithheaders(
        csvPath: "login-audit.csv", startIndex: 1, batchSize: 1,
        headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "something else"},
        filterColumName: "username", filterOperator: "!=", filterValue: local!usernameToFilterOut
      ).totalCount,
      null()
    ),
    refreshInterval: 0.5
  ),
  local!isTotalCountStale: and(
    local!isViewingToday,
    local!backgroundAutoRefreshedTotalCount <> local!currentLog.totalCount
  ),

  /* using brute force a bit here to tell if any logs exist for any of the past 4 days */
  local!hasPreviousDay: or(
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-1, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-2, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-3, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-4, "yyyy-mm-dd") ).totalCount > -1
  ),

  local!powerBiCounter: a!refreshVariable(
    value: readcsvlogpagingwithheaders(
      csvPath: "login-audit.csv" & local!dayString,
      startIndex: 1,
      batchSize: 1,
      headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "something else"},
      filterColumName: "username",
      filterOperator: "=",
      filterValue: local!usernameToFilterOut
    ).totalCount,
    refreshOnVarChange: {local!refreshCounter}
  ),

  local!splitVals: a!forEach(
    local!currentLog.rows,
    split(fv!item, ",")
  ),

  local!values: a!forEach(
    local!splitVals,

    a!localVariables(
      local!usernameAttempted: fv!item[2],  /* represents what the user typed in the login screen - causes complications... */
      local!username: lower(local!usernameAttempted),

      a!map(
        username: local!username,
        usernameAttempted: local!usernameAttempted,
        gmtTimestampString: fv!item[1],
        convertedTimeValue: local(gmt(parsedate(fv!item[1]))),  /* parseDate() is from "date and time utilities" plug-in */
        success: fv!item[3],
        ipAddress: fv!item[4],
        mode: fv!item[5]
      )
    )
  ),
  local!filteredUsernames: {"internal.service.account2", "service.account.3"},  /* list any other API / service accounts here (if any) when they may have a lot of routine hits not relevant for your use of this tool */

  local!filteredValues: a!forEach(
    local!values,
    if(contains(local!filteredUsernames, fv!item.username), {}, fv!item)
  ),

  local!usernameFilter: null(),
  local!successFilter: "either",

  local!filteringActive: or(
    a!isNotNullOrEmpty(local!usernameFilter),
    local!successFilter <> "either"
  ),

  local!reFilteredValues: if(
    not(local!filteringActive),
    local!filteredValues,

    a!flatten(a!forEach(
      local!filteredValues,
      if(
        and(
          or(
            a!isNullOrEmpty(local!usernameFilter),
            find(local!usernameFilter, fv!item.username) > 0
          ),
          or(
            local!successFilter = "either",
            and(
              local!successFilter = "success",
              fv!item.success = "Succeeded"
            ),
            and(
              local!successFilter = "fail",
              fv!item.success = "Failed"
            )
          )
        ),
        fv!item,
        {}
      )
    ))
  ),

  local!pagingInfo: a!pagingInfo(1, 20, a!sortInfo(field: "convertedTimeValue", ascending: false())),

  local!dataSubset: todatasubset(
    arrayToPage: local!reFilteredValues,
    pagingConfiguration: local!pagingInfo
  ),


  /* bending over backwards here to do the (somewhat costly) username-status-checking query (hitting query record type for user record for each line-item) just for the current page, post-filtering, etc. */
  local!updatedDataSubset: a!update(
    local!dataSubset,
    "data",
    a!forEach(
      local!dataSubset.data,
      a!update(
        data: fv!item,
        index: {"actualUsername", "usernameExists", "accountActive"},
        value: {
          a!localVariables(
            local!currentUsername: fv!item.username,
            local!currentUserStatus: rule!MIKE_UTIL_getCaseSensitiveUsernameAccountInfo(username: local!currentUsername),
            local!isActive: and(local!currentUserStatus.exists, a!defaultValue(local!currentUserStatus.isActive, false())),
            {
              local!currentUserStatus.username,
              local!currentUserStatus.exists,
              local!isActive
            }
          )
        }
      )
    )
  ),
  
  local!positiveColor: "#28724F", /* darker green */
  local!warningColor: "#E29262", /* warning color */
  local!negativeColor: "#BA0C2F", /* darker red */
  local!inactiveColor: "#bcbdc0", /* lighter grey */


  a!sectionLayout(
    label: "Login Audit Log Reader",
    contents: {
      a!columnsLayout(
        columns: {
          a!columnLayout(
            contents: {
              a!textField(
                labelPosition: "COLLAPSED",
                placeholder: "(filter by username)",
                value: local!usernameFilter,
                saveInto: local!usernameFilter,
                refreshAfter: "KEYPRESS"
              ),
              a!richTextDisplayField(
                labelPosition: "COLLAPSED",
                value: {
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "refresh",
                        caption: if(local!isTotalCountStale, "Refresh Needed (new info found)", "Refresh"),
                        link: a!dynamicLink(
                          saveInto: {
                            a!save(local!refreshCounter, local!refreshCounter + 1)
                          },
                          showWhen: local!isViewingToday
                        ),
                        linkStyle: "STANDALONE",
                        showWhen: local!isViewingToday,
                        color: if(local!isTotalCountStale, local!warningColor, "ACCENT"),
                        size: if(local!isTotalCountStale, "LARGE", null())
                      ),
                      a!richTextIcon(
                        icon: "refresh",
                        caption: "Currently viewing a previous day's log file, so 'Refresh' is not really relevant here.",
                        showWhen: not(local!isViewingToday),
                        color: local!inactiveColor,
                        size: "SMALL"
                      ),
                      a!richTextItem(
                        text: {
                          "           ",
                          a!richTextIcon(
                            icon: "remove",
                            caption: "Reset Filter(s)",
                            link: {
                              a!dynamicLink(
                                saveInto: {
                                  a!save(local!usernameFilter, ""),
                                  a!save(local!successFilter, "either"),
                                  a!save(local!pagingInfo.startIndex, 1),
                                  a!save(local!refreshCounter, local!refreshCounter + 1)
                                },
                                showWhen: local!filteringActive
                              )
                            },
                            linkStyle: "STANDALONE",
                            color: if(
                              local!filteringActive,
                              "NEGATIVE",
                              local!inactiveColor
                            ),
                            size: "MEDIUM_PLUS"
                          )
                        }
                      )
                    }
                  )
                }
              )
            },
            width: "9X"
          ),
          a!columnLayout(
            contents: {

              a!radioButtonField(
                choiceLabels: {"Success", "Failure", "Both"},
                choiceValues: {"success", "fail", "either"},
                labelPosition: "COLLAPSED",
                value: local!successFilter,
                saveInto: local!successFilter,
                choiceLayout: "COMPACT",
                marginBelow: "MORE"
              ),
              
              a!richTextDisplayField(
                labelPosition: "COLLAPSED",
                value: {
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "backward",
                        caption: "Back 1 Day",
                        color: if(
                          local!hasPreviousDay,
                          "",
                          local!inactiveColor
                        )
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, local!relevantDay - 1),
                        a!save(local!pagingInfo.startIndex, 1)
                      },
                      showWhen: local!hasPreviousDay
                    ),
                    linkStyle: "STANDALONE"
                  ),
                  "  ",
                  a!richTextItem(
                    text: text(local!relevantDay, "yyyy-mm-dd")
                  ),

                  "  ",
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "forward",
                        caption: "Forward 1 Day",
                        color: if(local!isViewingToday, local!inactiveColor, "")
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, local!relevantDay + 1),
                        a!save(local!pagingInfo.startIndex, 1)
                      },
                      showWhen: not(local!isViewingToday)
                    ),
                    linkStyle: "STANDALONE"
                  ),
                  " ",
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "fast-forward",
                        caption: "Jump to Today"
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, today()),
                        a!save(local!pagingInfo.startIndex, 1)
                      }
                    ),
                    linkStyle: "STANDALONE",
                    showWhen: local!relevantDay < today()-1
                  )
                },
                marginAbove: "LESS"
              )
            },
            width: "4X"
          )
        }
      ),

      a!gridField(
        data: local!updatedDataSubset,
        columns: {
          a!gridColumn(
            label: "Username",
            sortField: "username",
            value: a!richTextDisplayField(
              value: {
                a!richTextItem(
                  text: {
                    fv!row.username,
                    " ",
                    a!richTextIcon(
                      icon: "check-circle-o",
                      caption: "Account Active",
                      showWhen: and(fv!row.usernameExists, fv!row.accountActive),
                      color: local!positiveColor,
                      size: "SMALL"
                    ),
                    a!richTextIcon(
                      icon: "remove",
                      caption: "Account Deactivated",
                      showWhen: and(fv!row.usernameExists, not(a!defaultValue(fv!row.accountActive, true()))),
                      color: local!warningColor,
                      size: "STANDARD"
                    ),
                    a!richTextIcon(
                      icon: "question-circle-o",
                      caption: "Unknown Username",
                      showWhen: not(a!defaultValue(fv!row.usernameExists, false())),
                      color: local!negativeColor,
                      size: "SMALL"
                    )
                  }
                )
              }
            )
          ),
          a!gridColumn(
            label: "Timestamp",
            sortField: "convertedTimeValue",
            value: a!richTextDisplayField(
              value: {
                a!richTextItem(
                  text: text(fv!row.convertedTimeValue, "yyyy-mm-dd HH:mm:ss")
                ),
                char(10),
                a!richTextItem(
                  text: "(GMT:  " & fv!row.gmtTimestampString & ")",
                  color: "SECONDARY",
                  size: "SMALL",
                  style: "EMPHASIS"
                )
              }
            )
          ),
          a!gridColumn(
            label: "Success",
            value: fv!row.success
          ),
          a!gridColumn(
            label: "Mode",
            value: fv!row.mode
          ),
          a!gridColumn(
            label: "IP",
            value: fv!row.ipAddress
          )
        },
        pagingSaveInto: local!pagingInfo,
        borderStyle: "STANDARD",
        shadeAlternateRows: true()
      ),

      a!richTextDisplayField(
        label: "PowerBI hits filtered out:",
        labelPosition: "ADJACENT",
        value: local!powerBiCounter
      )
    }
  )
/* by: Mike Schmitt */
)

and here's the "MIKE_UTIL_getCaseSensitiveUsernameAccountInfo()" code:

a!localVariables(
  local!username: a!defaultValue(ri!username, "-=-=-"),
  local!userQuery: a!refreshVariable(
    refreshAlways: true(),
    value: a!queryRecordType(
      recordType: 'recordType!{SYSTEM_RECORD_TYPE_USER}User',
      pagingInfo: a!pagingInfo(1, 1),
      fetchTotalCount: true(),
      fields: {'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username', 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_active}active', 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_uuid}uuid'},
      filters: a!queryLogicalExpression(
        operator: "OR",
        filters: {
          /* doing this to rule out lowercase username first, as it's the normal way we try to initialize them */
          a!queryFilter(
            field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
            operator: "=",
            value: lower(local!username)
          ),
          a!queryFilter(
            field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
            operator: "=",
            value: local!username
          )
        },
        logicalExpressions: {
          a!queryLogicalExpression(
            operator: "AND",
    
            /* these will hopefully require the username to match exactly except for casing */
            filters: {
              a!queryFilter(
                field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
                operator: "starts with",
                value: local!username
              ),
              a!queryFilter(
                field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
                operator: "ends with",
                value: local!username
              )
            }
          )
        }
      )
    )
  ),
  
  if(
    local!userQuery.totalCount = 0,
    a!map(
      exists: false()
    ),
    a!map(
      exists: true(),
      username: tostring(local!userQuery.data['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username']),
      uuid: local!userQuery.data[1]['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_uuid}uuid'],
      isActive: local!userQuery.data[1]['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_active}active']
    )
  )
)

(i'm GUESSING this should copy/paste across systems, but there's a chance you might need to manually fix the record type association)

If there are any questions or suggestions, please let me know!

  Discussion posts and replies are publicly visible

Parents Reply Children
No Data