Article Working with Auditing

Auditing provides several extensibility points for you to enable customizations to be audited. This article explains how to utilize Community's new Auditing functionality for your own custom entities.

Implementing Auditing Components on Entities

  • Telligent.Evolution.Extensibility.Api.Entities.Version1.IAuditable
    • Implement this interface on any entities you wish to be audited
    • You provide four data points for the Auditing API to reference:
      • string Identifier - Typically the object’s id, but anything that will identify the object as uniquely as possibly in the log.
      • string Description - The name of the object or a short description generated from its key attributes.
      • string Url - If the object has a front end URL, include it here and it will be direct linked from the audit log for quick navigation.
      • IAuditable Location - if the object is part of a hierarchy with another IAuditable object (e.g. a Content, Application, or Container), include a reference to the object.
  • Telligent.Evolution.Extensibility.Api.Entities.Version1.IExtendedAuditable
    • By default when called, Auditing will check all public editable properties for changes. Implement this interface when you'd like to add additional information to be checked by the Auditing process.
    • Example Usages:
      • In order to include concrete values for Properties that may be accessed via parameterized methods, such as a Body() method with a render Target parameter.
      • In concert with an [IAuditableIgnore] attribute (see below) in order to provide a custom rendering of a property, such as a list of values or a foreign key/id.
  • Telligent.Evolution.Extensibility.Api.Entities.Version1.AuditableIgnore
    • When implementing IAuditable on your object, add the [AuditableIgnore] attribute tag for any property you want to exclude from auditing change detection.
    • Example usages:
      • Automated properties that are ancillary to other auditing changes, such as a User's Last Login Date when the login action is explicitly logged separately.
      • Sensitive information such as secret tokens or keys, where Auditing could act as a breach vector.
      • Properties that are internal or otherwise not changed as a direct result of user action, like a Last Calculated Date from a job.

Registering Actions For Auditing

When custom code that you write makes changes to entites that have implemented IAuditable, you can use the built-in Auditing API methods to alert Community to those changes. Calls to the Auditing API will be reflected in the Auditing log.

  • Telligent.Evolution.Extensibility.Api.Version1.IAuditing
    • When your customization creates, edits, or deletes entities that implement IAuditable, call the appropriate method and pass the entity(ies):
      • AdditionalInfo Created<T>(T createdEntity)
      • AdditionalInfo Deleted<T>(T deletedEntity)
      • AdditionalInfo Edited<T>(T originalEntity, T editedEntity)
        • When passing original and edited entities to Edited, ensure the two objects don't share the same reference.
    • These methods are already implemented for extensible APIs provided by Community; you don’t need to call these methods when making changes to Community-defined types via those APIs.

Sample Code

Below is an example implementation of a simple Application/Content data structure. Notebooks store a list of simple Notes, and can be locked via password, which is all facilitated via a basic service layer. Storage is handled in memory only for simplicity of the example.

Note: The password logic used here is for illustrative purposes only.

public class Notebook : IAuditable
{
	public Guid Id { get; set; }
	public string Url { get; set; }
	public string Title { get; set; }

	public string Description { get; set; }

	public DateTime DateCreated { get; set; }
	public DateTime? DateUpdated { get; set; }

	public bool IsLocked { get; set; }

	// Password used to restrict who can view the content
	// NOTE: This is illustrative only! Not a secure implementation.
	[AuditableIgnore]
	public string Password { get; set; }

	string IAuditable.Identifier => Id.ToString();

	string IAuditable.Description => Title;

	IAuditable IAuditable.Location => null;

	string IAuditable.Url => Url;
}

public class Note : IExtendedAuditable
{
	public Note(string rawBody)
	{
		_rawBody = rawBody;
	}

	public Guid Id { get; set; }
	public string Url { get; set; }
	public string Title { get; set; }
	public Notebook Notebook { get; set; }

	private string _rawBody;
	public string Body(string target)
	{
		if (target == "raw")
			return _rawBody;
		else
			return WebUtility.HtmlEncode(_rawBody);
	}

	public DateTime DateCreated { get; set; }
	public DateTime? DateUpdated { get; set; }

	string IAuditable.Identifier => Id.ToString();

	string IAuditable.Description => Title;

	IAuditable IAuditable.Location => Notebook;

	string IAuditable.Url => Url;

	IEnumerable<ExtendedAuditableValue> IExtendedAuditable.ExtendedAuditableValues => 
		new [] { new ExtendedAuditableValue("Body", Body("raw")), };
}

public class NotebookService
{
	private readonly IAuditing _auditing;
	private readonly NotebookData _data;

	public NotebookService()
	{
		_auditing = Apis.Get<IAuditing>();
		_data = new NotebookData();
	}

	public Guid CreateNotebook(string title, string description, string password = null)
	{
		var id = Guid.NewGuid();
		var created = DateTime.UtcNow;

		var newNotebook = new Notebook
		{
			Id = id,
			Title = title,
			Description = description,
			DateCreated = created
		};

		if (!string.IsNullOrEmpty(password))
		{
			newNotebook.IsLocked = true;
			newNotebook.Password = password;
		}

		_data.Notebooks.Add(id, newNotebook);
		_auditing.Created(newNotebook);

		return id;
	}

	public Notebook GetNotebook(Guid id, string password = null)
	{
		if(!_data.Notebooks.ContainsKey(id))
			throw new Exception($"Notebook with id '{id}' was not found.");

		var notebook = _data.Notebooks[id];
		if(notebook.IsLocked && notebook.Password != password)
			throw new Exception($"Access to notebook '{notebook.Title}' is password protected.");

		// Auditing depends on having separate reference objects for diffing.
		// Typically this would be done naturally as an instantiation coming from the db,
		// but here we fake it with a copy operation.
		return new Notebook
		{
			Id = notebook.Id,
			DateCreated = notebook.DateCreated,
			DateUpdated = notebook.DateUpdated,
			Description = notebook.Description,
			IsLocked = notebook.IsLocked,
			Password = notebook.Password,
			Title = notebook.Title,
			Url = notebook.Url
		};
	}

	public void UpdateNotebook(Guid id, string password = null, NotebookUpdateOptions options = null)
	{
		if(options == null)
			options = new NotebookUpdateOptions();

		var originalNotebook = GetNotebook(id, password);
		var updatedNotebook = GetNotebook(id, password);

		updatedNotebook.DateUpdated = DateTime.UtcNow;
		if (!string.IsNullOrEmpty(options.Title))
			updatedNotebook.Title = options.Title;
		if (!string.IsNullOrEmpty(options.Description))
			updatedNotebook.Description = options.Description;
		if (!string.IsNullOrEmpty(options.Password))
			updatedNotebook.Password = options.Password;
		if (options.IsLocked != null)
		{
			updatedNotebook.IsLocked = options.IsLocked.Value;
		}

		_data.Notebooks[id] = updatedNotebook;
		_auditing.Edited(originalNotebook, updatedNotebook);
	}

	public void DeleteNotebook(Guid id, string password = null)
	{
		var notebook = GetNotebook(id, password);
		_data.Notebooks.Remove(id);
		_auditing.Deleted(notebook);
	}

	public Guid CreateNote(string title, string body, Guid notebookId, string password = null)
	{
		var id = Guid.NewGuid();
		var created = DateTime.UtcNow;

		var notebook = GetNotebook(notebookId, password);

		var newNote = new Note(body)
		{
			Id = id,
			Title = title,
			DateCreated = created,
			Notebook = notebook
		};

		_data.Notes.Add(id, newNote);
		_auditing.Created(newNote);

		return id;
	}

	public Note GetNote(Guid id)
	{
		if (!_data.Notes.ContainsKey(id))
			throw new Exception($"Notebook with id '{id}' was not found.");

		var note = _data.Notes[id];
		return new Note(note.Body("raw"))
		{
			Id = note.Id,
			DateCreated = note.DateCreated,
			DateUpdated = note.DateUpdated,
			Notebook = note.Notebook,
			Title = note.Title,
			Url = note.Url
		};
	}

	public void UpdateNote(Guid id, NoteUpdateOptions options = null)
	{
		if (options == null)
			options = new NoteUpdateOptions();

		var originalNote = GetNote(id);

		var updatedNote = new Note(string.IsNullOrEmpty(options.Body) ? originalNote.Body("raw") : options.Body)
		{
			Id = originalNote.Id,
			DateCreated = originalNote.DateCreated,
			DateUpdated = DateTime.UtcNow,
			Title = string.IsNullOrEmpty(options.Title) ? originalNote.Title : options.Title,
			Url = originalNote.Url,
			Notebook = originalNote.Notebook
		};

		_data.Notes[id] = updatedNote;
		_auditing.Edited(originalNote, updatedNote);
	}

	public void DeleteNote(Guid id)
	{
		var note = GetNote(id);
		_data.Notes.Remove(id);
		_auditing.Deleted(note);
	}

	private class NotebookData
	{
		public Dictionary<Guid, Notebook> Notebooks { get; }
		public Dictionary<Guid, Note> Notes { get; }

		public NotebookData()
		{
			Notebooks = new Dictionary<Guid, Notebook>();
			Notes = new Dictionary<Guid, Note>();
		}
	}
}

public class NotebookUpdateOptions
{
	public string Title { get; set; }
	public string Description { get; set; }
	public string Password { get; set; }
	public bool? IsLocked { get; set; }
}

public class NoteUpdateOptions
{
	public string Title { get; set; }
	public string Body { get; set; }
}

Note the use of explicit implementation of the IAuditable interface. Among other things, this helps to limit confusion in references between Id and IAuditable.Identity, Description and IAuditable.Description, etc. Another important point was referenced above and depends on how you manage your data access and retrieval. When calling IAuditing.Edited<T>(T originalEntity, T editedEntity), you must ensure that the two objects are in fact different and do not share a reference. Otherwise, any edits to one would by reference edit the other, and audit diff tracking would not find any differences.

Below is a simple plugin that performs some actions that log messages to the Auditing Log.

public class NotebookSamplePlugin : IPlugin
{
	public string Name => "Auditing Sample Generator: Notebooks";
	public string Description => "This plugin will generate sample activity that will populate the Audit Log";

	private NotebookService _service;

	public void Initialize()
	{
		PluginManager.AfterInitialization += PluginManager_AfterInitialization;
	}

	private void PluginManager_AfterInitialization(object sender, EventArgs e)
	{
		_service = new NotebookService();

		Test1();
		Test2();
	}

	private void Test1()
	{
		var notebook1Id = _service.CreateNotebook("My Notebook", "A place to take notes");

		var note1Id = _service.CreateNote("My First Note", "Auditing is great!", notebook1Id);

		_service.UpdateNote(note1Id, new NoteUpdateOptions
		{
			Title = "My First Updated Note",
			Body = "Auditing is really great!"
		});

		_service.DeleteNote(note1Id);

		_service.DeleteNotebook(notebook1Id);
	}

	private void Test2()
	{
		var notebook2Id = _service.CreateNotebook("My Secret Notebook", "A place to take notes");

		_service.UpdateNotebook(notebook2Id, null, new NotebookUpdateOptions
		{
			Description = "A place to take notes... secretly",
			IsLocked = true,
			Password = "password"
		});

		var note2Id = _service.CreateNote("My Second Note", "Auditing is great!", notebook2Id, password:"password");

		_service.UpdateNote(note2Id, new NoteUpdateOptions
		{
			Title = "My Second Updated Note",
			Body = "Auditing is really great!"
		});

		_service.DeleteNote(note2Id);

		_service.DeleteNotebook(notebook2Id, "password");
	}
}

This will give the following in the log: